mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-06 23:58:35 +00:00
Merge pull request #24 from anikeen-com/feature/app-charges
Add charges and the billing limit for trusted apps
This commit is contained in:
12 files changed
+1045
-6
No files matched your search
+8
-1
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']]));
|
||||||
|
|||||||
@@ -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' => [
|
||||||
|
|||||||
Reference in new issue
Block a user