= limit: start no new paid service * @property string $resets_at when this month's bills are closed (ISO 8601) * @property int $version only goes up; keep the highest seen (also in the billing_limit.* webhooks) */ class BillingLimit implements JsonSerializable { public function __construct(protected object $data) { } public function __get(string $name): mixed { return $this->data->{$name} ?? null; } public function __isset(string $name): bool { return isset($this->data->{$name}); } /** * The user has set a limit (`0.00` is one too: no new charges at all). */ public function hasLimit(): bool { return $this->limit() !== null; } /** * Gross, null without a limit. */ public function limit(): ?string { return $this->data->limit ?? null; } /** * The user's open monthly bills across all Anikeen services (gross). Null for an app while the * user has no limit. */ public function used(): ?string { return $this->data->used ?? null; } /** * The open monthly bills of your app (gross); null outside an app read (billingOverview()). * `used − used_by_app` is what the user spends on their other Anikeen services. */ public function usedByApp(): ?string { return $this->data->used_by_app ?? null; } /** * Gross room left (never negative), null without a limit. */ public function remaining(): ?string { return $this->data->remaining ?? null; } /** * The room left as a net amount at the user's VAT rate, rounded down: a hint only (the VAT of * a charge depends on its type). Null without a limit or a billing address. */ public function remainingNet(): ?string { return $this->data->remaining_net ?? null; } /** * The user's VAT rate in percent (e.g. 19.0), null without a billing address. */ public function vatRate(): ?float { return isset($this->data->vat_rate) ? (float) $this->data->vat_rate : null; } public function currency(): string { return $this->data->currency ?? 'EUR'; } /** * The limit is reached (used >= limit): start no new paid service for the user. */ public function isReached(): bool { return (bool) ($this->data->reached ?? false); } /** * When this month's bills are closed and `used` drops. */ public function resetsAt(): ?DateTimeImmutable { return isset($this->data->resets_at) ? new DateTimeImmutable($this->data->resets_at) : null; } /** * Only goes up: keep the highest one seen and ignore webhooks with a lower or equal one. */ public function version(): int { return (int) ($this->data->version ?? 0); } /** * Whether a charge of $gross (gross, e.g. "4.76") would fit as of this read: always without a * limit, else at most `remaining`. Advisory only: it reserves nothing, the binding check is * the booking (409 "billing_limit_reached"). */ public function allows(string|int|float $gross): bool { if (!$this->hasLimit()) { return true; } $remaining = $this->remaining(); return $remaining !== null && self::compare($gross, $remaining) <= 0; } public function toArray(): array { return (array) $this->data; } public function jsonSerialize(): array { return $this->toArray(); } /** * Compares two non-negative decimal amounts exactly (as digit strings, no float rounding); * negative amounts count as zero. */ private static function compare(string|int|float $a, string|int|float $b): int { return strcmp(self::micros($a), self::micros($b)) <=> 0; } /** * The amount in millionths as a zero-padded digit string of a fixed width. */ private static function micros(string|int|float $amount): string { $amount = is_float($amount) ? number_format($amount, 6, '.', '') : trim((string) $amount); if (str_starts_with($amount, '-')) { return str_repeat('0', 40); } [$whole, $fraction] = array_pad(explode('.', ltrim($amount, '+'), 2), 2, ''); $digits = preg_replace('/\D/', '', $whole) . substr(str_pad(preg_replace('/\D/', '', $fraction), 6, '0'), 0, 6); return str_pad(ltrim($digits, '0'), 40, '0', STR_PAD_LEFT); } }