Document charges and the billing limit

This commit is contained in:
envoyr committed 2026-11-06 20:01:15 +00:00
1 parent 7bd7249730
commit 32103f6921
2 files changed
+274 -4

No files matched your search

+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,