Compare commits

...
Author SHA1 Message Date
envoyr 3d7ce65f61 Merge pull request #24 from anikeen-com/feature/app-charges
Add charges and the billing limit for trusted apps
2026-11-06 21:25:38 +00:00
envoyr b35c066b48 Merge pull request #23 from anikeen-com/feature/preferences
Add the preferences API
2026-11-06 21:25:11 +00:00
envoyr be91df6d18 Restore CRLF line endings in ManagesBalance 2026-11-06 20:03:12 +00:00
envoyr 3a78359f59 Add charges and the billing limit to the changelog 2026-11-06 20:01:15 +00:00
envoyr 32103f6921 Document charges and the billing limit 2026-11-06 20:01:15 +00:00
envoyr 7bd7249730 Add charges and the billing limit for trusted apps 2026-11-06 20:00:26 +00:00
envoyr f5d48b7e35 Add the billing limit event types 2026-11-06 20:00:26 +00:00
12 changed files with 1045 additions and 6 deletions

No files matched your search

+8 -1
View File
@@ -2,7 +2,7 @@
## Unreleased ## Unreleased
Preferences; they need an Anikeen ID release that supports them. Preferences, charges and the billing limit; they need an Anikeen ID release that supports them.
### Added ### Added
@@ -18,6 +18,13 @@ Preferences; they need an Anikeen ID release that supports them.
`PreconditionFailedException` (a subclass of `ApiException`) for 412 with `currentVersion()`. `PreconditionFailedException` (a subclass of `ApiException`) for 412 with `currentVersion()`.
- Scopes `Scope::PREFERENCES`, `PREFERENCES_READ` and `PREFERENCES_CLIENT`. - Scopes `Scope::PREFERENCES`, `PREFERENCES_READ` and `PREFERENCES_CLIENT`.
- Webhook event `order.refunded` (`EventType::ORDER_REFUNDED`). - Webhook event `order.refunded` (`EventType::ORDER_REFUNDED`).
- Charges on a user's monthly bill for trusted apps: `createChargeForUser()` (with an idempotency
key), `findChargeForUser()` and `voidChargeForUser()`; `Resources\AppCharge`.
- The user's billing limit for trusted apps: `billingLimitForUser()` and `Resources\BillingLimit`
(`allows()` is advisory); the `billing_limit` key of `billingOverview()` is documented.
- `ApiException::isBillingLimitReached()` for 409 `billing_limit_reached`.
- Opt-in webhook events `billing_limit.reached` and `billing_limit.available`
(`EventType::BILLING_LIMIT_REACHED`, `BILLING_LIMIT_AVAILABLE`).
- An optional trailing `$headers` argument on `AnikeenId::request()`, `get()`, `post()`, `put()`, - An optional trailing `$headers` argument on `AnikeenId::request()`, `get()`, `post()`, `put()`,
`delete()` and `AppRequest::send()`; calls without it send the same request as before. `delete()` and `AppRequest::send()`; calls without it send the same request as before.
+139 -2
View File
@@ -214,11 +214,14 @@ public function boot(): void
| Something that recurs (a pass, a plan) | [Subscriptions](#subscriptions) | user | the user, on Anikeen ID | | Something that recurs (a pass, a plan) | [Subscriptions](#subscriptions) | user | the user, on Anikeen ID |
| Several subscriptions in one step | [Checkout sessions](#checkout-sessions-the-user-confirms-several-subscriptions-at-once) | user or app | the user, on Anikeen ID | | Several subscriptions in one step | [Checkout sessions](#checkout-sessions-the-user-confirms-several-subscriptions-at-once) | user or app | the user, on Anikeen ID |
| Usage billing from your backend, without the user online | [App subscriptions](#app-subscriptions-trusted-apps-no-user-token) | app | your app (trusted apps only) | | Usage billing from your backend, without the user online | [App subscriptions](#app-subscriptions-trusted-apps-no-user-token) | app | your app (trusted apps only) |
| Single items on the user's monthly bill (e.g. a shipped item) | [Charges](#charges-and-billing-limit-trusted-apps) | app | your app (trusted apps only) |
Most apps are third-party apps: they create an order or subscription as a draft, send the user to Most apps are third-party apps: they create an order or subscription as a draft, send the user to
Anikeen ID to confirm and pay, and get the user back afterwards. Anikeen ID shows the items, asks Anikeen ID to confirm and pay, and get the user back afterwards. Anikeen ID shows the items, asks
for missing addresses, takes the payment and writes the invoice. Trusted apps (operated by for missing addresses, takes the payment and writes the invoice. Trusted apps (operated by
Anikeen) can also charge directly; see [Trusted apps](#trusted-apps). Anikeen) can also charge directly; see [Trusted apps](#trusted-apps). For a per-item charge on
the user's monthly bill use `createChargeForUser()`, not `createOrderForUser()`: an order is a
separate one-off purchase, charged on its own.
The flow, as in a typical shop: The flow, as in a typical shop:
@@ -558,6 +561,11 @@ if ($event['type'] === EventType::SUBSCRIPTION_RENEWED) {
`order.*` events list the order's `items` and `invoice` like `order.*` events list the order's `items` and `invoice` like
[app orders](#app-orders-trusted-apps-no-user-token). [app orders](#app-orders-trusted-apps-no-user-token).
`billing_limit.reached` and `billing_limit.available` (`EventType::BILLING_LIMIT_REACHED`,
`BILLING_LIMIT_AVAILABLE`) tell trusted apps that a user's billing limit was reached or has room
again; they are opt-in like `subscription.renewed`, see
[Charges and billing limit](#charges-and-billing-limit-trusted-apps).
`order.refunded` (`EventType::ORDER_REFUNDED`) is sent when money was given back for an order (a `order.refunded` (`EventType::ORDER_REFUNDED`) is sent when money was given back for an order (a
refund or a partial cancellation, with a credit note); the order carries its `refunds` and refund or a partial cancellation, with a credit note); the order carries its `refunds` and
`refunded_total`. A full refund also cancels the order, so `order.cancelled` is sent too; don't rely on the `refunded_total`. A full refund also cancels the order, so `order.cancelled` is sent too; don't rely on the
@@ -573,7 +581,11 @@ well as with the app's (`app*()`). It carries:
`already_cancelled`), otherwise `null`, `already_cancelled`), otherwise `null`,
- `getMessage()`: Anikeen ID's reason, - `getMessage()`: Anikeen ID's reason,
- `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry - `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry
later). later),
- `isBillingLimitReached()` (409 `billing_limit_reached`: a [charge](#charges-and-billing-limit-trusted-apps)
would take the user's monthly bill above their billing limit),
- `body()`: the error response, for errors with details (e.g. `remaining` of
`billing_limit_reached`).
```php ```php
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
@@ -851,6 +863,127 @@ link's user. The session ends after an hour without activity or on "Back to *app
payment) and back to `return_url`. For the signed-in user with their own token, payment) and back to `return_url`. For the signed-in user with their own token,
`$user->billingPortalUrl()` stays available. `$user->billingPortalUrl()` stays available.
### Charges and billing limit (trusted apps)
Trusted apps can book single items onto a user's **open monthly bill** of the app, e.g. each item
a shipping app sends: one line per charge, no subscription, charged with the bill on the 1st like
every other line (invoice and VAT by Anikeen ID). Anikeen has to enable charging for your app and
sets a maximum per charge; the user must be connected to your app (they authorised it, or your app
bills them through subscriptions or orders).
```php
$anikeen = app(AnikeenId::class);
$charge = $anikeen->createChargeForUser($user->anikeen_id, [
'idempotency_key' => "shipment-{$shipment->id}", // per app and user: a retry returns the same charge
'name' => 'Signed card', // the line on the bill
'type' => 'physical', // digital (default), physical, physical-reduced
'quantity' => 2,
'unit_price' => '4.00', // net per piece, up to 6 decimals
'reference' => "order-{$shipment->order_id}", // your own id; not on the bill
// 'group' => $team->id, // only a group your app already bills the user in
// 'occurred_at' => '2026-11-05T14:00:00Z', // the service date, at most 31 days ago
]);
$charge->id(); // keep it to void the charge later
$charge->grossAmount(); // "9.52": what it added to the bill
$charge->orderId(); // the monthly bill it is on
$charge->status(); // open, billed (the bill was closed) or voided
$anikeen->findChargeForUser($user->anikeen_id, $chargeId);
$anikeen->voidChargeForUser($user->anikeen_id, $chargeId, 'Parcel lost'); // while the bill is open
```
- **Idempotency:** retrying with the same key and the same fields returns the existing charge
(200 instead of 201), also after the limit was reached; the same key with other fields is 409
`idempotency_key_conflict` (`charge_id` in `body()`). Use one key per item you charge for.
- **Voiding** removes the line while the bill is open (an already voided charge is returned as
it is). Once the bill was closed: 409 `charge_already_billed`; only a refund of the order by
Anikeen credits it.
- **`group`** is null or a group your app already bills the user in (a running subscription or an
open monthly bill of your app); others are refused with 409 `group_not_allowed`.
- `reference` and `metadata` (up to 20 string values) are your data about the user, kept with the
charge and not shown on the bill: put nothing sensitive there.
**Billing limit.** A user can set one monthly limit (gross) for their whole open monthly bill
across all Anikeen services. A charge that would take the bill above it is refused with 409
`billing_limit_reached`; exactly reaching the limit is allowed, `0.00` refuses every charge.
Subscription renewals and usage are still billed, so the bill can end above the limit. Read the
limit to stop offering paid actions early (advisory only, it reserves nothing):
```php
$limit = $anikeen->billingLimitForUser($user->anikeen_id);
$limit->hasLimit(); // false: no limit, every charge fits
$limit->isReached(); // used >= limit: start no new paid service
$limit->remaining(); // "17.60" gross room left, null without a limit
$limit->remainingNet(); // "14.78" at the user's VAT rate, a hint
$limit->allows('9.52'); // would this charge fit right now (advisory)
$limit->resetsAt(); // when this month's bills are closed
$limit->version(); // only goes up
$limit->used(); // the user's open bills across all Anikeen services, null without a limit
$limit->usedByApp(); // your app's share; used − usedByApp = "your other Anikeen services"
```
`used` (the user's open bills across all Anikeen services) is visible to apps **only while the
user has set a limit**; without one `limit`, `used`, `remaining` and `remaining_net` are null.
`usedByApp()` (your app's own open bills) is always there. The user sees their own limit in
`$user->billingOverview()->billing_limit` (with `used`, without `used_by_app`; null while they
can't set one). Apps that may charge can read the limit; others need Anikeen to allow it (403
`billing_limit_not_enabled`).
Handling a refused charge:
```php
use Anikeen\Id\Exceptions\ApiException;
try {
$charge = $anikeen->createChargeForUser($user->anikeen_id, $attributes);
} catch (ApiException $e) {
if ($e->isBillingLimitReached()) {
$e->body()->remaining; // room left before this charge, e.g. "2.60"
$e->body()->required; // the refused charge, gross
$e->body()->resets_at; // when the limit has room again at the latest
return $shipment->holdForBillingLimit();
}
match ($e->error) {
'charge_exceeds_maximum' => report($e), // above your app's maximum ($e->body()->max_charge_net)
'group_not_allowed' => report($e),
'billing_address_missing' => $shipment->askForBillingAddress(),
default => throw $e,
};
}
```
**Webhooks.** `billing_limit.reached` and `billing_limit.available` are sent when the limit is
reached or has room again (a raised or removed limit, a void, the monthly reset). They are
**opt-in** (select them for your endpoint; `*` doesn't include them) and go only to apps that may
read the limit of a user connected to them. `data.object` is the billing limit as your app reads
it, `data.reason` says why (`booking`, `limit_changed`, `limit_removed`, `voided`,
`period_reset`, `order_changed`). Keep the highest `version` per user and ignore events with a
lower or equal one, so a late delivery can't undo a newer state. In the `match` of the
[webhook route](#webhooks):
```php
EventType::BILLING_LIMIT_REACHED, EventType::BILLING_LIMIT_AVAILABLE => Customer::syncBillingLimit(
$object['user_id'], reached: $event['type'] === EventType::BILLING_LIMIT_REACHED, version: $object['version'],
),
```
The limit is checked live when a charge is booked, so a late or missed webhook never lets a
charge through; poll `billingLimitForUser()` if you need the current state.
#### Error codes
| Status | `error` |
|--------|---------|
| 403 | `client_not_allowed` (not a trusted app with its own token), `charges_not_enabled` (charging is not enabled for your app; also for reads and voids), `billing_limit_not_enabled` (limit read only) |
| 404 | `user_not_found` (unknown, deleted or not connected to your app), `charge_not_found` |
| 409 | `billing_limit_reached` (`isBillingLimitReached()`), `charge_exceeds_maximum` (+ `max_charge_net`), `group_not_allowed`, `idempotency_key_conflict` (+ `charge_id`), `billing_address_missing`, `charge_already_billed` (+ `charge_id`) |
| 422 | validation (`errors`), no `error` code |
| 429 | `too_many_requests` (`isTemporary()`) |
### Checkout sessions (the user confirms several subscriptions at once) ### Checkout sessions (the user confirms several subscriptions at once)
Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID, Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID,
@@ -1290,6 +1423,10 @@ public function appCheckoutSessions(): AppCheckoutSessions
public function createBillingPortalLinkForUser(string $userId, array $attributes = []): object public function createBillingPortalLinkForUser(string $userId, array $attributes = []): object
public function createOrderForUser(string $userId, array $attributes): Result public function createOrderForUser(string $userId, array $attributes): Result
public function createTransactionForUser(string $userId, array $attributes): Result public function createTransactionForUser(string $userId, array $attributes): Result
public function createChargeForUser(string $userId, array $attributes): AppCharge
public function findChargeForUser(string $userId, string $chargeId): AppCharge
public function voidChargeForUser(string $userId, string $chargeId, ?string $reason = null): AppCharge
public function billingLimitForUser(string $userId): BillingLimit
``` ```
### ManagesSocialiteUsers ### ManagesSocialiteUsers
+135 -2
View File
@@ -214,11 +214,14 @@ public function boot(): void
| Something that recurs (a pass, a plan) | [Subscriptions](#subscriptions) | user | the user, on Anikeen ID | | Something that recurs (a pass, a plan) | [Subscriptions](#subscriptions) | user | the user, on Anikeen ID |
| Several subscriptions in one step | [Checkout sessions](#checkout-sessions-the-user-confirms-several-subscriptions-at-once) | user or app | the user, on Anikeen ID | | Several subscriptions in one step | [Checkout sessions](#checkout-sessions-the-user-confirms-several-subscriptions-at-once) | user or app | the user, on Anikeen ID |
| Usage billing from your backend, without the user online | [App subscriptions](#app-subscriptions-trusted-apps-no-user-token) | app | your app (trusted apps only) | | Usage billing from your backend, without the user online | [App subscriptions](#app-subscriptions-trusted-apps-no-user-token) | app | your app (trusted apps only) |
| Single items on the user's monthly bill (e.g. a shipped item) | [Charges](#charges-and-billing-limit-trusted-apps) | app | your app (trusted apps only) |
Most apps are third-party apps: they create an order or subscription as a draft, send the user to Most apps are third-party apps: they create an order or subscription as a draft, send the user to
Anikeen ID to confirm and pay, and get the user back afterwards. Anikeen ID shows the items, asks Anikeen ID to confirm and pay, and get the user back afterwards. Anikeen ID shows the items, asks
for missing addresses, takes the payment and writes the invoice. Trusted apps (operated by for missing addresses, takes the payment and writes the invoice. Trusted apps (operated by
Anikeen) can also charge directly; see [Trusted apps](#trusted-apps). Anikeen) can also charge directly; see [Trusted apps](#trusted-apps). For a per-item charge on
the user's monthly bill use `createChargeForUser()`, not `createOrderForUser()`: an order is a
separate one-off purchase, charged on its own.
The flow, as in a typical shop: The flow, as in a typical shop:
@@ -558,6 +561,11 @@ if ($event['type'] === EventType::SUBSCRIPTION_RENEWED) {
`order.*` events list the order's `items` and `invoice` like `order.*` events list the order's `items` and `invoice` like
[app orders](#app-orders-trusted-apps-no-user-token). [app orders](#app-orders-trusted-apps-no-user-token).
`billing_limit.reached` and `billing_limit.available` (`EventType::BILLING_LIMIT_REACHED`,
`BILLING_LIMIT_AVAILABLE`) tell trusted apps that a user's billing limit was reached or has room
again; they are opt-in like `subscription.renewed`, see
[Charges and billing limit](#charges-and-billing-limit-trusted-apps).
`order.refunded` (`EventType::ORDER_REFUNDED`) is sent when money was given back for an order (a `order.refunded` (`EventType::ORDER_REFUNDED`) is sent when money was given back for an order (a
refund or a partial cancellation, with a credit note); the order carries its `refunds` and refund or a partial cancellation, with a credit note); the order carries its `refunds` and
`refunded_total`. A full refund also cancels the order, so `order.cancelled` is sent too; don't rely on the `refunded_total`. A full refund also cancels the order, so `order.cancelled` is sent too; don't rely on the
@@ -573,7 +581,11 @@ well as with the app's (`app*()`). It carries:
`already_cancelled`), otherwise `null`, `already_cancelled`), otherwise `null`,
- `getMessage()`: Anikeen ID's reason, - `getMessage()`: Anikeen ID's reason,
- `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry - `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry
later). later),
- `isBillingLimitReached()` (409 `billing_limit_reached`: a [charge](#charges-and-billing-limit-trusted-apps)
would take the user's monthly bill above their billing limit),
- `body()`: the error response, for errors with details (e.g. `remaining` of
`billing_limit_reached`).
```php ```php
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
@@ -851,6 +863,127 @@ link's user. The session ends after an hour without activity or on "Back to *app
payment) and back to `return_url`. For the signed-in user with their own token, payment) and back to `return_url`. For the signed-in user with their own token,
`$user->billingPortalUrl()` stays available. `$user->billingPortalUrl()` stays available.
### Charges and billing limit (trusted apps)
Trusted apps can book single items onto a user's **open monthly bill** of the app, e.g. each item
a shipping app sends: one line per charge, no subscription, charged with the bill on the 1st like
every other line (invoice and VAT by Anikeen ID). Anikeen has to enable charging for your app and
sets a maximum per charge; the user must be connected to your app (they authorised it, or your app
bills them through subscriptions or orders).
```php
$anikeen = app(AnikeenId::class);
$charge = $anikeen->createChargeForUser($user->anikeen_id, [
'idempotency_key' => "shipment-{$shipment->id}", // per app and user: a retry returns the same charge
'name' => 'Signed card', // the line on the bill
'type' => 'physical', // digital (default), physical, physical-reduced
'quantity' => 2,
'unit_price' => '4.00', // net per piece, up to 6 decimals
'reference' => "order-{$shipment->order_id}", // your own id; not on the bill
// 'group' => $team->id, // only a group your app already bills the user in
// 'occurred_at' => '2026-11-05T14:00:00Z', // the service date, at most 31 days ago
]);
$charge->id(); // keep it to void the charge later
$charge->grossAmount(); // "9.52": what it added to the bill
$charge->orderId(); // the monthly bill it is on
$charge->status(); // open, billed (the bill was closed) or voided
$anikeen->findChargeForUser($user->anikeen_id, $chargeId);
$anikeen->voidChargeForUser($user->anikeen_id, $chargeId, 'Parcel lost'); // while the bill is open
```
- **Idempotency:** retrying with the same key and the same fields returns the existing charge
(200 instead of 201), also after the limit was reached; the same key with other fields is 409
`idempotency_key_conflict` (`charge_id` in `body()`). Use one key per item you charge for.
- **Voiding** removes the line while the bill is open (an already voided charge is returned as
it is). Once the bill was closed: 409 `charge_already_billed`; only a refund of the order by
Anikeen credits it.
- **`group`** is null or a group your app already bills the user in (a running subscription or an
open monthly bill of your app); others are refused with 409 `group_not_allowed`.
- `reference` and `metadata` (up to 20 string values) are your data about the user, kept with the
charge and not shown on the bill: put nothing sensitive there.
**Billing limit.** A user can set one monthly limit (gross) for their whole open monthly bill
across all Anikeen services. A charge that would take the bill above it is refused with 409
`billing_limit_reached`; exactly reaching the limit is allowed, `0.00` refuses every charge.
Subscription renewals and usage are still billed, so the bill can end above the limit. Read the
limit to stop offering paid actions early (advisory only, it reserves nothing):
```php
$limit = $anikeen->billingLimitForUser($user->anikeen_id);
$limit->hasLimit(); // false: no limit, every charge fits
$limit->isReached(); // used >= limit: start no new paid service
$limit->remaining(); // "17.60" gross room left, null without a limit
$limit->remainingNet(); // "14.78" at the user's VAT rate, a hint
$limit->allows('9.52'); // would this charge fit right now (advisory)
$limit->resetsAt(); // when this month's bills are closed
$limit->version(); // only goes up
$limit->used(); // the user's open bills across all Anikeen services, null without a limit
$limit->usedByApp(); // your app's share; used − usedByApp = "your other Anikeen services"
```
`used` (the user's open bills across all Anikeen services) is visible to apps **only while the
user has set a limit**; without one `limit`, `used`, `remaining` and `remaining_net` are null.
`usedByApp()` (your app's own open bills) is always there. The user sees their own limit in
`$user->billingOverview()->billing_limit` (with `used`, without `used_by_app`; null while they
can't set one). Apps that may charge can read the limit; others need Anikeen to allow it (403
`billing_limit_not_enabled`).
Handling a refused charge:
```php
use Anikeen\Id\Exceptions\ApiException;
try {
$charge = $anikeen->createChargeForUser($user->anikeen_id, $attributes);
} catch (ApiException $e) {
if ($e->isBillingLimitReached()) {
$e->body()->remaining; // room left before this charge, e.g. "2.60"
$e->body()->required; // the refused charge, gross
$e->body()->resets_at; // when the limit has room again at the latest
return $shipment->holdForBillingLimit();
}
match ($e->error) {
'charge_exceeds_maximum' => report($e), // above your app's maximum ($e->body()->max_charge_net)
'group_not_allowed' => report($e),
'billing_address_missing' => $shipment->askForBillingAddress(),
default => throw $e,
};
}
```
**Webhooks.** `billing_limit.reached` and `billing_limit.available` are sent when the limit is
reached or has room again (a raised or removed limit, a void, the monthly reset). They are
**opt-in** (select them for your endpoint; `*` doesn't include them) and go only to apps that may
read the limit of a user connected to them. `data.object` is the billing limit as your app reads
it, `data.reason` says why (`booking`, `limit_changed`, `limit_removed`, `voided`,
`period_reset`, `order_changed`). Keep the highest `version` per user and ignore events with a
lower or equal one, so a late delivery can't undo a newer state. In the `match` of the
[webhook route](#webhooks):
```php
EventType::BILLING_LIMIT_REACHED, EventType::BILLING_LIMIT_AVAILABLE => Customer::syncBillingLimit(
$object['user_id'], reached: $event['type'] === EventType::BILLING_LIMIT_REACHED, version: $object['version'],
),
```
The limit is checked live when a charge is booked, so a late or missed webhook never lets a
charge through; poll `billingLimitForUser()` if you need the current state.
#### Error codes
| Status | `error` |
|--------|---------|
| 403 | `client_not_allowed` (not a trusted app with its own token), `charges_not_enabled` (charging is not enabled for your app; also for reads and voids), `billing_limit_not_enabled` (limit read only) |
| 404 | `user_not_found` (unknown, deleted or not connected to your app), `charge_not_found` |
| 409 | `billing_limit_reached` (`isBillingLimitReached()`), `charge_exceeds_maximum` (+ `max_charge_net`), `group_not_allowed`, `idempotency_key_conflict` (+ `charge_id`), `billing_address_missing`, `charge_already_billed` (+ `charge_id`) |
| 422 | validation (`errors`), no `error` code |
| 429 | `too_many_requests` (`isTemporary()`) |
### Checkout sessions (the user confirms several subscriptions at once) ### Checkout sessions (the user confirms several subscriptions at once)
Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID, Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID,
+7 -1
View File
@@ -31,7 +31,13 @@ trait ManagesBalance
/** /**
* Billing overview of the current user: current_balance, current_charges, vat_rate, * Billing overview of the current user: current_balance, current_charges, vat_rate,
* has_billing_address, has_default_payment_method, open_orders. * 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 * @throws Throwable
*/ */
+99
View File
@@ -3,8 +3,10 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCharge;
use Anikeen\Id\Resources\AppCheckoutSessions; use Anikeen\Id\Resources\AppCheckoutSessions;
use Anikeen\Id\Resources\AppOrders; use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Resources\BillingLimit;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use Anikeen\Id\Support\AppRequest; use Anikeen\Id\Support\AppRequest;
use Throwable; use Throwable;
@@ -123,4 +125,101 @@ trait ManagesUserBilling
{ {
return $this->asApp()->request('POST', 'v1/admin/transactions', [...$attributes, 'user_id' => $userId]); 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<string, string>,
* 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);
}
} }
+10
View File
@@ -79,6 +79,16 @@ class ApiException extends ResourceException
return $this->status === 409; 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. * The ID service could not be reached (network error, timeout) or failed (5xx): retry later.
*/ */
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace Anikeen\Id\Resources;
use DateTimeImmutable;
use JsonSerializable;
/**
* A charge a trusted app booked onto a user's open monthly bill, see
* AnikeenId::createChargeForUser(). Amounts are decimal strings (`unit_price` with six decimals,
* the others with two).
*
* @property string $object "charge"
* @property string $id
* @property string $user_id
* @property string $status open (its bill is open), billed (the bill was closed) or voided
* @property string $idempotency_key
* @property string $name the line on the bill
* @property ?string $description
* @property string $type digital, physical or physical-reduced (the VAT treatment)
* @property int $quantity
* @property string $unit "piece"
* @property string $unit_price net per piece
* @property string $net_amount
* @property string $gross_amount what the charge added to the bill, VAT included
* @property string $currency_iso
* @property ?string $group
* @property string $occurred_at the line's service date (ISO 8601)
* @property ?string $reference the app's own id
* @property object $metadata the app's own data (string values)
* @property ?string $order_id the monthly bill (order) the line is on; follows the line when a rollover moves it
* @property ?string $voided_at
* @property ?string $void_reason
* @property string $created_at
*/
class AppCharge implements JsonSerializable
{
public const STATUS_OPEN = 'open';
public const STATUS_BILLED = 'billed';
public const STATUS_VOIDED = 'voided';
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});
}
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<string, string>
*/
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();
}
}
+186
View File
@@ -0,0 +1,186 @@
<?php
namespace Anikeen\Id\Resources;
use DateTimeImmutable;
use JsonSerializable;
/**
* A user's monthly billing limit: one gross limit for their whole open monthly bill across all
* Anikeen services, see AnikeenId::billingLimitForUser() and the `billing_limit` key of
* billingOverview(). Amounts are decimal strings with two decimals.
*
* Without a limit `limit`, `remaining` and `remaining_net` are null; for an app `used` is null
* too (an app sees the user's whole open bill only while they have set a limit). `used_by_app`
* (the open bills of your app) is there only when an app reads the limit.
*
* @property string $object "billing_limit"
* @property string $user_id
* @property string $currency "EUR"
* @property string $basis "gross"
* @property ?string $limit
* @property ?string $used the user's open monthly bills across all Anikeen services
* @property ?string $used_by_app the open monthly bills of your app (app reads only)
* @property ?string $remaining max(0, limit − used)
* @property ?string $remaining_net remaining at the user's VAT rate, rounded down (a hint)
* @property int|float|null $vat_rate null without a billing address
* @property bool $reached used >= 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);
}
}
+15
View File
@@ -61,6 +61,21 @@ final class EventType
/** A checkout session was not confirmed within 24 hours; its drafts were cancelled. */ /** A checkout session was not confirmed within 24 hours; its drafts were cancelled. */
public const CHECKOUT_EXPIRED = 'checkout.expired'; public const CHECKOUT_EXPIRED = 'checkout.expired';
/**
* A user's open monthly bill reached their billing limit: start no new paid service for them.
* `data.object` is the billing limit as the app reads it (with `version`), `data.reason`
* `booking` or `limit_changed`. Opt-in: only endpoints that select this event get it (not
* through `*`), and only apps that may read the limit of a user connected to them.
*/
public const BILLING_LIMIT_REACHED = 'billing_limit.reached';
/**
* A user whose billing limit was reached has room again. `data.object` is the billing limit
* (with `version`), `data.reason` `limit_changed`, `limit_removed`, `voided`, `period_reset`
* or `order_changed`. Opt-in like BILLING_LIMIT_REACHED.
*/
public const BILLING_LIMIT_AVAILABLE = 'billing_limit.available';
public const TEST = 'webhook.test'; public const TEST = 'webhook.test';
/** /**
+269
View File
@@ -0,0 +1,269 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCharge;
use Anikeen\Id\Resources\BillingLimit;
use DateTimeImmutable;
class AppChargesTest extends TestCase
{
private function sentCall(int $index): string
{
$request = $this->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());
}
}
}
+30
View File
@@ -55,6 +55,36 @@ class EndpointsTest extends TestCase
$this->assertSame('GET /api/v1/billing Bearer user-token', $this->sentCall(0)); $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 public function test_invoice_download_url_is_read_not_updated(): void
{ {
$this->respond(200, $this->list([['id' => 'inv1']])); $this->respond(200, $this->list([['id' => 'inv1']]));
+8
View File
@@ -76,6 +76,14 @@ class WebhookTest extends TestCase
$this->assertContains(EventType::ORDER_REFUNDED, EventType::all()); $this->assertContains(EventType::ORDER_REFUNDED, EventType::all());
} }
public function test_billing_limit_events_have_their_own_types(): void
{
$this->assertSame('billing_limit.reached', EventType::BILLING_LIMIT_REACHED);
$this->assertSame('billing_limit.available', EventType::BILLING_LIMIT_AVAILABLE);
$this->assertContains(EventType::BILLING_LIMIT_REACHED, EventType::all());
$this->assertContains(EventType::BILLING_LIMIT_AVAILABLE, EventType::all());
}
public function test_a_change_carries_the_previous_attributes(): void public function test_a_change_carries_the_previous_attributes(): void
{ {
$this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [ $this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [