From 7bd724973048ae31e4c54c8aadf8a6c0992423cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Fri, 6 Nov 2026 20:00:26 +0000 Subject: [PATCH] Add charges and the billing limit for trusted apps --- src/Id/Concerns/ManagesBalance.php | 126 ++++++------ src/Id/Concerns/ManagesUserBilling.php | 99 +++++++++ src/Id/Exceptions/ApiException.php | 10 + src/Id/Resources/AppCharge.php | 139 +++++++++++++ src/Id/Resources/BillingLimit.php | 186 +++++++++++++++++ tests/Id/AppChargesTest.php | 269 +++++++++++++++++++++++++ tests/Id/EndpointsTest.php | 30 +++ 7 files changed, 799 insertions(+), 60 deletions(-) create mode 100644 src/Id/Resources/AppCharge.php create mode 100644 src/Id/Resources/BillingLimit.php create mode 100644 tests/Id/AppChargesTest.php diff --git a/src/Id/Concerns/ManagesBalance.php b/src/Id/Concerns/ManagesBalance.php index 7913096..1595b26 100644 --- a/src/Id/Concerns/ManagesBalance.php +++ b/src/Id/Concerns/ManagesBalance.php @@ -1,60 +1,66 @@ -getUserData()->current_balance; - } - - /** - * Get charges from the current user. - * - * @throws Throwable - */ - public function charges(): float - { - return $this->getUserData()->current_charges; - } - - /** - * Billing overview of the current user: current_balance, current_charges, vat_rate, - * has_billing_address, has_default_payment_method, open_orders. - * - * @throws Throwable - */ - public function billingOverview(): object - { - return $this->anikeenId()->request('GET', 'v1/billing')->data; - } - - /** - * Charge given amount (deposit) from one of the current user's payment methods. - * - * @param float|string $amount Amount in euros (0.50 to 10,000, at most 2 decimals). - * @param string $paymentMethodId Stripe payment method ID (pm_…). - * @param array $options Ignored; Stripe options can no longer be passed through. - * @throws Throwable - */ - public function charge(float|string $amount, string $paymentMethodId, array $options = []): Transaction - { - return (new Transaction(fn() => $this->anikeenId() - ->request('POST', 'v1/billing/charge', [ - 'amount' => $amount, - 'payment_method_id' => $paymentMethodId, - ]))) - ->setBillable($this); - } -} \ No newline at end of file +getUserData()->current_balance; + } + + /** + * Get charges from the current user. + * + * @throws Throwable + */ + public function charges(): float + { + return $this->getUserData()->current_charges; + } + + /** + * Billing overview of the current user: current_balance, current_charges, vat_rate, + * has_billing_address, has_default_payment_method, open_orders and billing_limit. + * + * billing_limit is the user's monthly billing limit {object: "billing_limit", user_id, + * currency, basis, limit, used, remaining, remaining_net, vat_rate, reached, resets_at, + * version} (`limit`, `remaining` and `remaining_net` null without a limit; wrap it in + * `new \Anikeen\Id\Resources\BillingLimit(…)` for its helpers), or null while the user has no + * limit and can't set one yet. Older Anikeen ID releases don't send the key. + * + * @throws Throwable + */ + public function billingOverview(): object + { + return $this->anikeenId()->request('GET', 'v1/billing')->data; + } + + /** + * Charge given amount (deposit) from one of the current user's payment methods. + * + * @param float|string $amount Amount in euros (0.50 to 10,000, at most 2 decimals). + * @param string $paymentMethodId Stripe payment method ID (pm_…). + * @param array $options Ignored; Stripe options can no longer be passed through. + * @throws Throwable + */ + public function charge(float|string $amount, string $paymentMethodId, array $options = []): Transaction + { + return (new Transaction(fn() => $this->anikeenId() + ->request('POST', 'v1/billing/charge', [ + 'amount' => $amount, + 'payment_method_id' => $paymentMethodId, + ]))) + ->setBillable($this); + } +} diff --git a/src/Id/Concerns/ManagesUserBilling.php b/src/Id/Concerns/ManagesUserBilling.php index 8d9986e..4c0d45a 100644 --- a/src/Id/Concerns/ManagesUserBilling.php +++ b/src/Id/Concerns/ManagesUserBilling.php @@ -3,8 +3,10 @@ namespace Anikeen\Id\Concerns; use Anikeen\Id\Exceptions\ApiException; +use Anikeen\Id\Resources\AppCharge; use Anikeen\Id\Resources\AppCheckoutSessions; use Anikeen\Id\Resources\AppOrders; +use Anikeen\Id\Resources\BillingLimit; use Anikeen\Id\Result; use Anikeen\Id\Support\AppRequest; use Throwable; @@ -123,4 +125,101 @@ trait ManagesUserBilling { return $this->asApp()->request('POST', 'v1/admin/transactions', [...$attributes, 'user_id' => $userId]); } + + /** + * Book a charge onto a user's open monthly bill of the app, e.g. a shipped item: one line, + * no subscription, charged with the bill on the 1st. Trusted apps that Anikeen allowed to + * charge (with a maximum per charge), for users connected to the app (they authorised it or + * the app bills them). The user's billing limit is checked when it is booked. + * + * Retrying with the same idempotency key and the same fields returns the existing charge + * (also after the limit was reached); other fields with that key: 409 + * "idempotency_key_conflict" (`charge_id` in body()). + * + * @param string $userId The user's Anikeen ID. + * @param array{ + * idempotency_key: string, + * name: string, + * description?: null|string, + * type?: string, + * quantity: int, + * unit_price: float|int|string, + * currency_iso?: string, + * group?: null|string, + * occurred_at?: string, + * reference?: null|string, + * metadata?: null|array, + * image_url?: null|string + * } $attributes idempotency_key: unique per app and user, ≤ 255 (e.g. "shipment-123"). + * name: the line on the bill, ≤ 255. description: ≤ 255. + * type: digital (default), physical or physical-reduced (the VAT treatment). + * quantity: 1–10000 pieces. unit_price: net per piece, > 0, ≤ 100000, up to 6 + * decimals; quantity × unit_price rounded to cents must be at least 0.01. + * currency_iso: EUR only (default). + * group: null, or a group in which the app already bills the user (a running + * subscription or an open monthly bill of the app). + * occurred_at: the service date, not in the future, at most 31 days ago + * (default now). + * reference: the app's own id (≤ 255). metadata: ≤ 20 keys (≤ 40 characters), + * string values ≤ 500. Both are not on the bill; put nothing sensitive there. + * image_url: http(s) URL of an image for the line. + * + * @throws ApiException 403 "client_not_allowed" (not a trusted app), "charges_not_enabled"; + * 404 "user_not_found" (unknown, deleted or not connected); 409 "billing_limit_reached" + * (isBillingLimitReached(); body() has limit, used, remaining, remaining_net, required, + * currency, resets_at), "charge_exceeds_maximum" (+ max_charge_net), + * "group_not_allowed", "idempotency_key_conflict" (+ charge_id), + * "billing_address_missing"; 422 validation; 429 "too_many_requests" + */ + public function createChargeForUser(string $userId, array $attributes): AppCharge + { + return new AppCharge(AppRequest::send('POST', $this->chargesPath($userId), $attributes)->data); + } + + /** + * A charge of the app (or of a client delegated to the same app) for a user. + * + * @throws ApiException 403 "client_not_allowed", "charges_not_enabled"; 404 "user_not_found", + * "charge_not_found"; 429 "too_many_requests" + */ + public function findChargeForUser(string $userId, string $chargeId): AppCharge + { + return new AppCharge(AppRequest::send('GET', $this->chargesPath($userId, $chargeId))->data); + } + + /** + * Void a charge while its bill is open: the line is removed and the bill's total drops. An + * already voided charge is returned as it is. Once the bill is closed only a refund of the + * order by Anikeen credits it. + * + * @param string|null $reason why (≤ 255), stored with the charge + * @throws ApiException 409 "charge_already_billed" (+ charge_id); 404 "charge_not_found", + * "user_not_found"; 403 "client_not_allowed", "charges_not_enabled"; 429 "too_many_requests" + */ + public function voidChargeForUser(string $userId, string $chargeId, ?string $reason = null): AppCharge + { + $payload = $reason === null ? null : ['reason' => $reason]; + + return new AppCharge(AppRequest::send('POST', $this->chargesPath($userId, $chargeId) . '/void', $payload)->data); + } + + /** + * A user's monthly billing limit as the app sees it, e.g. to pause paid features while it is + * reached. Advisory only: it reserves nothing; a charge is checked when it is booked. Trusted + * apps that may charge or that Anikeen allowed to read limits, for users connected to the app. + * + * @throws ApiException 403 "client_not_allowed", "billing_limit_not_enabled"; 404 + * "user_not_found"; 429 "too_many_requests" + */ + public function billingLimitForUser(string $userId): BillingLimit + { + return new BillingLimit(AppRequest::send('GET', sprintf('v1/client/users/%s/billing-limit', rawurlencode($userId)))->data); + } + + private function chargesPath(string $userId, ?string $chargeId = null): string + { + $path = sprintf('v1/client/users/%s/charges', rawurlencode($userId)); + + return $chargeId === null ? $path : $path . '/' . rawurlencode($chargeId); + } } diff --git a/src/Id/Exceptions/ApiException.php b/src/Id/Exceptions/ApiException.php index e218dba..a6b2d0b 100644 --- a/src/Id/Exceptions/ApiException.php +++ b/src/Id/Exceptions/ApiException.php @@ -79,6 +79,16 @@ class ApiException extends ResourceException return $this->status === 409; } + /** + * A charge was refused because it would take the user's open monthly bill above their billing + * limit (409 "billing_limit_reached"). body() has limit, used, remaining, remaining_net, + * required, currency and resets_at. + */ + public function isBillingLimitReached(): bool + { + return $this->status === 409 && $this->error === 'billing_limit_reached'; + } + /** * The ID service could not be reached (network error, timeout) or failed (5xx): retry later. */ diff --git a/src/Id/Resources/AppCharge.php b/src/Id/Resources/AppCharge.php new file mode 100644 index 0000000..26f1dc3 --- /dev/null +++ b/src/Id/Resources/AppCharge.php @@ -0,0 +1,139 @@ +data->{$name} ?? null; + } + + public function __isset(string $name): bool + { + return isset($this->data->{$name}); + } + + public function id(): string + { + return $this->data->id; + } + + /** + * open, billed or voided (the STATUS_* constants). + */ + public function status(): string + { + return $this->data->status; + } + + /** + * Its bill is still open: the charge can be voided. + */ + public function isOpen(): bool + { + return $this->status() === self::STATUS_OPEN; + } + + /** + * Its bill was closed (paid, waiting for payment or cancelled): only a refund of the order by + * Anikeen credits it. + */ + public function isBilled(): bool + { + return $this->status() === self::STATUS_BILLED; + } + + public function isVoided(): bool + { + return $this->status() === self::STATUS_VOIDED; + } + + /** + * Net, e.g. "8.00". + */ + public function netAmount(): string + { + return $this->data->net_amount; + } + + /** + * Gross, what the charge added to the bill, e.g. "9.52". + */ + public function grossAmount(): string + { + return $this->data->gross_amount; + } + + /** + * The monthly bill (order) the charge is on, null once its line is gone (voided). + */ + public function orderId(): ?string + { + return $this->data->order_id ?? null; + } + + /** + * @return array + */ + public function metadata(): array + { + return (array) ($this->data->metadata ?? []); + } + + public function occurredAt(): ?DateTimeImmutable + { + return isset($this->data->occurred_at) ? new DateTimeImmutable($this->data->occurred_at) : null; + } + + public function toArray(): array + { + return (array) $this->data; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Id/Resources/BillingLimit.php b/src/Id/Resources/BillingLimit.php new file mode 100644 index 0000000..4a9a6f1 --- /dev/null +++ b/src/Id/Resources/BillingLimit.php @@ -0,0 +1,186 @@ += 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); + } +} diff --git a/tests/Id/AppChargesTest.php b/tests/Id/AppChargesTest.php new file mode 100644 index 0000000..67fc0d0 --- /dev/null +++ b/tests/Id/AppChargesTest.php @@ -0,0 +1,269 @@ +sent[$index]['request']; + + return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization'); + } + + private function charge(array $overrides = []): array + { + return ['data' => [ + 'object' => 'charge', 'id' => '01JCHARGE', 'user_id' => '01JUSER', 'status' => 'open', + 'idempotency_key' => 'shipment-123', 'name' => 'Signed card', 'description' => null, + 'type' => 'physical', 'quantity' => 2, 'unit' => 'piece', 'unit_price' => '4.000000', + 'net_amount' => '8.00', 'gross_amount' => '9.52', 'currency_iso' => 'EUR', 'group' => null, + 'occurred_at' => '2026-11-06T10:00:00+00:00', 'reference' => 'order-77', 'metadata' => ['parcel' => 'p-1'], + 'order_id' => '01JORDER', 'voided_at' => null, 'void_reason' => null, 'created_at' => '2026-11-06T10:00:01+00:00', + ...$overrides, + ]]; + } + + private function limit(array $overrides = []): array + { + return ['data' => [ + 'object' => 'billing_limit', 'user_id' => '01JUSER', 'currency' => 'EUR', 'basis' => 'gross', + 'limit' => '100.00', 'used' => '82.40', 'used_by_app' => '60.00', 'remaining' => '17.60', + 'remaining_net' => '14.78', 'vat_rate' => 19, 'reached' => false, + 'resets_at' => '2026-12-01T02:00:00Z', 'version' => 7, + ...$overrides, + ]]; + } + + public function test_a_charge_is_booked_with_the_app_token(): void + { + $this->respond(201, $this->charge()); + + $attributes = [ + 'idempotency_key' => 'shipment-123', + 'name' => 'Signed card', + 'type' => 'physical', + 'quantity' => 2, + 'unit_price' => '4.00', + 'reference' => 'order-77', + 'metadata' => ['parcel' => 'p-1'], + ]; + $charge = app(AnikeenId::class)->createChargeForUser('01JUSER', $attributes); + + $this->assertSame('POST /api/v1/client/users/01JUSER/charges Bearer app-token', $this->sentCall(0)); + $this->assertSame($attributes, $this->sentJson(0)); + $this->assertInstanceOf(AppCharge::class, $charge); + $this->assertSame('01JCHARGE', $charge->id()); + $this->assertSame('open', $charge->status()); + $this->assertTrue($charge->isOpen()); + $this->assertFalse($charge->isBilled()); + $this->assertFalse($charge->isVoided()); + $this->assertSame('8.00', $charge->netAmount()); + $this->assertSame('9.52', $charge->grossAmount()); + $this->assertSame('01JORDER', $charge->orderId()); + $this->assertSame(['parcel' => 'p-1'], $charge->metadata()); + $this->assertEquals(new DateTimeImmutable('2026-11-06T10:00:00+00:00'), $charge->occurredAt()); + $this->assertSame(2, $charge->quantity); + $this->assertSame('order-77', $charge->reference); + $this->assertNull($charge->voided_at); + $this->assertSame('Signed card', $charge->jsonSerialize()['name']); + } + + public function test_empty_metadata_is_an_empty_array(): void + { + $this->respond(200, $this->charge(['metadata' => new \stdClass()])); + + $this->assertSame([], app(AnikeenId::class)->findChargeForUser('01JUSER', '01JCHARGE')->metadata()); + } + + public function test_a_charge_is_found_with_the_app_token(): void + { + $this->respond(200, $this->charge(['status' => 'billed'])); + + $charge = app(AnikeenId::class)->findChargeForUser('01JUSER', '01JCHARGE'); + + $this->assertSame('GET /api/v1/client/users/01JUSER/charges/01JCHARGE Bearer app-token', $this->sentCall(0)); + $this->assertTrue($charge->isBilled()); + } + + public function test_a_charge_is_voided_with_and_without_a_reason(): void + { + $voided = ['status' => 'voided', 'order_id' => null, 'voided_at' => '2026-11-06T11:00:00+00:00', 'void_reason' => 'Parcel lost']; + $this->respond(200, $this->charge($voided)); + $this->respond(200, $this->charge($voided)); + + $charge = app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE', 'Parcel lost'); + app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE'); + + $this->assertSame('POST /api/v1/client/users/01JUSER/charges/01JCHARGE/void Bearer app-token', $this->sentCall(0)); + $this->assertSame(['reason' => 'Parcel lost'], $this->sentJson(0)); + $this->assertSame('POST /api/v1/client/users/01JUSER/charges/01JCHARGE/void Bearer app-token', $this->sentCall(1)); + $this->assertSame([], $this->sentJson(1)); + $this->assertTrue($charge->isVoided()); + $this->assertNull($charge->orderId()); + $this->assertSame('Parcel lost', $charge->void_reason); + } + + public function test_path_segments_are_encoded(): void + { + $this->respond(200, $this->charge()); + + app(AnikeenId::class)->findChargeForUser('01J/USER', '01J CHARGE'); + + $this->assertSame('/api/v1/client/users/01J%2FUSER/charges/01J%20CHARGE', $this->sent[0]['request']->getUri()->getPath()); + } + + public function test_a_charge_over_the_billing_limit_carries_the_details(): void + { + $this->respond(409, [ + 'message' => 'The customer\'s billing limit is reached.', 'error' => 'billing_limit_reached', + 'limit' => '100.00', 'used' => '97.40', 'remaining' => '2.60', 'remaining_net' => '2.18', + 'required' => '4.76', 'currency' => 'EUR', 'resets_at' => '2026-12-01T02:00:00Z', + ]); + + try { + app(AnikeenId::class)->createChargeForUser('01JUSER', ['idempotency_key' => 'k', 'name' => 'Card', 'quantity' => 1, 'unit_price' => '4.00']); + $this->fail('Expected an ApiException.'); + } catch (ApiException $e) { + $this->assertTrue($e->isBillingLimitReached()); + $this->assertTrue($e->isConflict()); + $this->assertSame(409, $e->status); + $this->assertSame('2.60', $e->body()->remaining); + $this->assertSame('4.76', $e->body()->required); + $this->assertSame('2026-12-01T02:00:00Z', $e->body()->resets_at); + } + } + + public function test_other_conflicts_are_not_the_billing_limit(): void + { + $this->respond(409, ['message' => 'The charge is more than this app may charge at once.', 'error' => 'charge_exceeds_maximum', 'max_charge_net' => '50.00']); + + try { + app(AnikeenId::class)->createChargeForUser('01JUSER', ['idempotency_key' => 'k', 'name' => 'Card', 'quantity' => 1, 'unit_price' => '60.00']); + $this->fail('Expected an ApiException.'); + } catch (ApiException $e) { + $this->assertFalse($e->isBillingLimitReached()); + $this->assertSame('charge_exceeds_maximum', $e->error); + $this->assertSame('50.00', $e->body()->max_charge_net); + } + } + + public function test_an_already_billed_charge_cannot_be_voided(): void + { + $this->respond(409, ['message' => 'The charge is on a closed bill; only a refund of that order credits it.', 'error' => 'charge_already_billed', 'charge_id' => '01JCHARGE']); + + try { + app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE'); + $this->fail('Expected an ApiException.'); + } catch (ApiException $e) { + $this->assertSame('charge_already_billed', $e->error); + $this->assertSame('01JCHARGE', $e->body()->charge_id); + } + } + + public function test_an_unknown_charge_is_not_found(): void + { + $this->respond(404, ['message' => 'Charge not found.', 'error' => 'charge_not_found']); + + try { + app(AnikeenId::class)->findChargeForUser('01JUSER', '01JOTHER'); + $this->fail('Expected an ApiException.'); + } catch (ApiException $e) { + $this->assertTrue($e->isNotFound()); + $this->assertSame('charge_not_found', $e->error); + } + } + + public function test_the_billing_limit_is_read_with_the_app_token(): void + { + $this->respond(200, $this->limit()); + + $limit = app(AnikeenId::class)->billingLimitForUser('01JUSER'); + + $this->assertSame('GET /api/v1/client/users/01JUSER/billing-limit Bearer app-token', $this->sentCall(0)); + $this->assertInstanceOf(BillingLimit::class, $limit); + $this->assertTrue($limit->hasLimit()); + $this->assertSame('100.00', $limit->limit()); + $this->assertSame('82.40', $limit->used()); + $this->assertSame('60.00', $limit->usedByApp()); + $this->assertSame('17.60', $limit->remaining()); + $this->assertSame('14.78', $limit->remainingNet()); + $this->assertSame(19.0, $limit->vatRate()); + $this->assertSame('EUR', $limit->currency()); + $this->assertFalse($limit->isReached()); + $this->assertEquals(new DateTimeImmutable('2026-12-01T02:00:00Z'), $limit->resetsAt()); + $this->assertSame(7, $limit->version()); + $this->assertSame('gross', $limit->basis); + } + + public function test_without_a_limit_the_app_sees_only_its_own_share(): void + { + $this->respond(200, $this->limit(['limit' => null, 'used' => null, 'remaining' => null, 'remaining_net' => null, 'vat_rate' => null])); + + $limit = app(AnikeenId::class)->billingLimitForUser('01JUSER'); + + $this->assertFalse($limit->hasLimit()); + $this->assertNull($limit->limit()); + $this->assertNull($limit->used()); + $this->assertNull($limit->remaining()); + $this->assertNull($limit->remainingNet()); + $this->assertNull($limit->vatRate()); + $this->assertSame('60.00', $limit->usedByApp()); + $this->assertTrue($limit->allows('100000.00')); + } + + public function test_allows_compares_with_the_remaining_amount(): void + { + $limit = new BillingLimit((object) $this->limit()['data']); + + $this->assertTrue($limit->allows('17.60')); + $this->assertTrue($limit->allows('17.6')); + $this->assertTrue($limit->allows('4.76')); + $this->assertTrue($limit->allows(17.6)); + $this->assertTrue($limit->allows(17)); + $this->assertFalse($limit->allows('17.61')); + $this->assertFalse($limit->allows('17.600001')); + $this->assertFalse($limit->allows('100.00')); + $this->assertFalse($limit->allows(1000)); + } + + public function test_a_reached_or_zero_limit_allows_nothing(): void + { + $reached = new BillingLimit((object) $this->limit(['used' => '100.00', 'remaining' => '0.00', 'remaining_net' => '0.00', 'reached' => true])['data']); + $zero = new BillingLimit((object) $this->limit(['limit' => '0.00', 'used' => '0.00', 'remaining' => '0.00', 'reached' => true])['data']); + + $this->assertTrue($reached->isReached()); + $this->assertFalse($reached->allows('0.01')); + $this->assertTrue($zero->hasLimit()); + $this->assertFalse($zero->allows('0.01')); + } + + public function test_the_user_s_own_limit_has_no_app_share(): void + { + $limit = new BillingLimit((object) ['object' => 'billing_limit', 'user_id' => '01JUSER', 'currency' => 'EUR', 'basis' => 'gross', 'limit' => null, 'used' => '12.00', 'remaining' => null, 'remaining_net' => null, 'vat_rate' => 19.0, 'reached' => false, 'resets_at' => '2026-12-01T02:00:00Z', 'version' => 0]); + + $this->assertSame('12.00', $limit->used()); + $this->assertNull($limit->usedByApp()); + $this->assertSame(0, $limit->version()); + } + + public function test_a_refused_limit_read_is_an_api_exception(): void + { + $this->respond(403, ['message' => 'This app may not read customers\' billing limits.', 'error' => 'billing_limit_not_enabled']); + + try { + app(AnikeenId::class)->billingLimitForUser('01JUSER'); + $this->fail('Expected an ApiException.'); + } catch (ApiException $e) { + $this->assertSame(403, $e->status); + $this->assertSame('billing_limit_not_enabled', $e->error); + $this->assertFalse($e->isBillingLimitReached()); + } + } +} diff --git a/tests/Id/EndpointsTest.php b/tests/Id/EndpointsTest.php index a03313f..66d7cd8 100644 --- a/tests/Id/EndpointsTest.php +++ b/tests/Id/EndpointsTest.php @@ -55,6 +55,36 @@ class EndpointsTest extends TestCase $this->assertSame('GET /api/v1/billing Bearer user-token', $this->sentCall(0)); } + public function test_billing_overview_carries_the_billing_limit(): void + { + $this->respond(200, ['data' => ['current_charges' => 11.9, 'billing_limit' => ['object' => 'billing_limit', 'limit' => '50.00', 'used' => '11.90', 'remaining' => '38.10', 'reached' => false, 'version' => 3]]]); + + $limit = new \Anikeen\Id\Resources\BillingLimit($this->owner()->billingOverview()->billing_limit); + + $this->assertSame('38.10', $limit->remaining()); + $this->assertTrue($limit->allows('38.10')); + $this->assertNull($limit->usedByApp()); + } + + public function test_charges_and_billing_limits_of_users_use_the_app_token(): void + { + $charge = ['data' => ['object' => 'charge', 'id' => '01JCHARGE', 'status' => 'open']]; + $this->respond(201, $charge); + $this->respond(200, $charge); + $this->respond(200, $charge); + $this->respond(200, ['data' => ['object' => 'billing_limit', 'limit' => null]]); + + app(AnikeenId::class)->createChargeForUser('01JUSER', ['idempotency_key' => 'k', 'name' => 'Card', 'quantity' => 1, 'unit_price' => '4.00']); + app(AnikeenId::class)->findChargeForUser('01JUSER', '01JCHARGE'); + app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE'); + app(AnikeenId::class)->billingLimitForUser('01JUSER'); + + $this->assertSame('POST /api/v1/client/users/01JUSER/charges Bearer app-token', $this->sentCall(0)); + $this->assertSame('GET /api/v1/client/users/01JUSER/charges/01JCHARGE Bearer app-token', $this->sentCall(1)); + $this->assertSame('POST /api/v1/client/users/01JUSER/charges/01JCHARGE/void Bearer app-token', $this->sentCall(2)); + $this->assertSame('GET /api/v1/client/users/01JUSER/billing-limit Bearer app-token', $this->sentCall(3)); + } + public function test_invoice_download_url_is_read_not_updated(): void { $this->respond(200, $this->list([['id' => 'inv1']]));