Compare commits

..
Author SHA1 Message Date
Maurice Preuß ade2970272 Document per-app collective orders and the order's oauth_client_id
Collective orders are kept per app on Anikeen ID now: an app's subscriptions
are never billed on another app's order, even with the same group. Document
oauth_client_id on orders (Order property, app orders) and how legacy orders
that billed several apps appear (own lines only, invoice PDF refused with
403 invoice_includes_other_apps).
2026-11-04 23:02:48 +01:00
Maurice Preuß a54b5fe598 Document trials without a payment method
trial_payment_method (TrialPaymentMethod::REQUIRED, the default, or
::OPTIONAL) on app subscriptions, subscriptions and checkout sessions:
an optional trial can be confirmed without a payment method and is
cancelled at its end if there is still none (cancellation_reason and
subscription.cancelled data.reason
CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD).
subscription.trial_will_end carries data.payment_method_missing.
2026-11-04 22:44:03 +01:00
Maurice Preuß a68e9ed257 Merge main into feature/uptimr-billing-api 2026-11-04 22:11:16 +01:00
envoyr 0ed6934da3 Merge pull request #16 from anikeen-com/fix/dependabot-alerts
Require patched Laravel and php-jwt versions
2026-11-04 22:09:39 +01:00
Maurice Preuß df7e506a5e Require patched Laravel and php-jwt versions
Raise the minimum versions so installs can no longer resolve releases with
known advisories (Dependabot alerts):

- laravel/framework / illuminate/*: ^12.69|^13.0 (TokenGuard auth bypass,
  CRLF injection in the email rule, signed URL path confusion, debug page XSS).
  Laravel 11 is end of life and gets no fixes, so support for it is dropped.
- firebase/php-jwt: ^7.0 (weak encryption in 6.x).
- orchestra/testbench: ^10.0|^11.0 to match.

Tests pass with both the lowest and the highest allowed versions.
2026-11-04 22:05:30 +01:00
Maurice Preuß 7f16155701 Document first_period on subscription.renewed and login-free billing portal links
- subscription.renewed is now sent for the first paid period too;
  data.period.first_period tells it apart (README, EventType, test payload).
- Billing portal links need no login, are single use and valid for about
  15 minutes: create them on click and redirect right away (README,
  createBillingPortalLinkForUser(), appBillingPortalUrl()).
2026-11-04 21:57:49 +01:00
Maurice Preuß 35ca9ddd2b Create billing portal links for users as a trusted app
AnikeenId::createBillingPortalLinkForUser() and $user->appBillingPortalUrl()
create a short-lived link to a user's billing pages with the app's token
(return_url, group, flow), e.g. for a Manage billing button of an app
that bills a team owner's account.
2026-11-04 21:27:26 +01:00
Maurice Preuß e45a871ca7 Let users confirm orders of trusted apps
confirm, return_url and metadata on createOrderForUser() and orders
created with the user's token: the order stays a draft with a
confirmation_url and expires after 24 hours (409 confirmation_expired on
checkout). metadata can be changed on a draft with Order::update().
2026-11-04 21:26:47 +01:00
Maurice Preuß bd56f114da Read order lines and invoices of app orders
Document the items (subscription, period, net amount, discount), invoice
and metadata of orders read with the app's token, add
AppOrders::invoicePdf() for the invoice PDF and the opt-in
subscription.renewed event type with its period data.
2026-11-04 21:25:56 +01:00
Maurice Preuß db3c50af72 Change running subscriptions
AppSubscription::change() changes price, unit, name, description or
discount of a running subscription, prorated right away (APPLY_NOW) or
from the next period on (APPLY_PERIOD_END, shown as scheduled_change);
cancelScheduledChange() drops a scheduled one. Adds the
subscription.updated event type and documents proration_not_possible
and credit_exceeds_bill.
2026-11-04 21:25:00 +01:00
Maurice Preuß bf21142f72 Discount subscriptions per billed period
discount (label, percent_off or amount_off, duration once, repeating or
forever) when creating subscriptions and per subscription of checkout
sessions, the discount field on subscriptions and the
subscription.discount_ended event type.
2026-11-04 21:24:22 +01:00
Maurice Preuß 5cc1a519c3 Start subscriptions with a free trial
trial_days, trial_ends_at and trial_once when creating subscriptions (app and
user token) and per subscription of checkout sessions. isTrial() on
subscriptions, the subscription.trial_will_end event type, and the
trial_already_used error code in the docs.
2026-11-04 21:23:52 +01:00
24 changed files with 1070 additions and 24 deletions

No files matched your search

+246 -5
View File
@@ -4,7 +4,7 @@
[![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
[![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
PHP Anikeen ID API Client for Laravel 11+ PHP Anikeen ID API Client for Laravel 12+
Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders, Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders,
subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID. subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID.
@@ -71,7 +71,7 @@ Add to `config/services.php` file:
### Event Listener ### Event Listener
In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method: Since Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
```php ```php
public function boot(): void public function boot(): void
@@ -361,8 +361,102 @@ same value as `status`):
| `expired` | Paused and ran out; `resume()` starts a new period | no | | `expired` | Paused and ran out; `resume()` starts a new period | no |
| `cancelled` | Cancelled for good | no | | `cancelled` | Cancelled for good | no |
There are shortcuts for each: `isDraft()`, `isPaused()`, `isCancelling()`, `isExpired()`, There are shortcuts for each: `isDraft()`, `isTrial()`, `isPaused()`, `isCancelling()`,
`isCancelled()`. App subscriptions (`appSubscriptions()`) have the same methods. `isExpired()`, `isCancelled()`. App subscriptions (`appSubscriptions()`) have the same methods.
#### Free trials
Give a subscription a free trial with `trial_days` (1–365, counted from the user's confirmation or
the checkout) or a fixed `trial_ends_at` (ISO 8601, in the future), not both. The subscription
starts right away with `status` `trial` and nothing billed; the first period is billed when the
trial ends. `trial_once` refuses the trial if the user already had one with your app in the same
`group` (422 when creating, 409 `trial_already_used` when checking out):
```php
$subscription = $user->subscriptions()->create([
'name' => 'Pixel Stream Pass',
'unit' => 'month',
'price' => 6.714286,
'trial_days' => 14,
'trial_once' => true,
]);
```
A few days before the trial ends, Anikeen ID sends `subscription.trial_will_end`
(`EventType::SUBSCRIPTION_TRIAL_WILL_END`), e.g. to remind the user; `data.payment_method_missing`
tells you whether the user still has to add a payment method. Cancelling during the trial
bills nothing. Trials work the same for app subscriptions and per subscription of a checkout
session.
By default the user needs a payment method to confirm a trial. With `trial_payment_method`
`TrialPaymentMethod::OPTIONAL` (only together with a trial; `TrialPaymentMethod::REQUIRED` is the
default) the user can confirm it without one, as long as nothing else has to be paid: in a
checkout session every subscription has to be such a trial. The confirmation page tells the user
to add a payment method before the trial ends. If there is still none then, the subscription is
**cancelled** at the trial's end instead of billed: `status` `cancelled`, `cancellation_reason`
`CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD`, `subscription.cancelled` with
`data.reason` set to the same value, and no `subscription.renewed`. Adding a payment method later
doesn't bring it back – create a new subscription. The trial still counts for `trial_once`.
Anikeen ID also mails the user a few days before such a trial ends without a payment method.
```php
use Anikeen\Id\Enums\CancellationReason;
use Anikeen\Id\Enums\TrialPaymentMethod;
use Anikeen\Id\Webhooks\EventType;
$subscription = $user->appSubscriptions()->create([
'group' => $teamId,
'name' => 'Uptimr Pro',
'unit' => 'month',
'price' => 19.00,
'trial_days' => 14,
'trial_payment_method' => TrialPaymentMethod::OPTIONAL,
]);
// in your webhook handler
$data = $event['data'];
match ($event['type']) {
EventType::SUBSCRIPTION_TRIAL_WILL_END => $data['payment_method_missing']
? Reminder::addPaymentMethod($data['object']['id'])
: null,
EventType::SUBSCRIPTION_CANCELLED => $data['reason'] === CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD
? Pass::downgrade($data['object']['id'])
: Pass::revoke($data['object']['id']),
default => null,
};
```
#### Discounts
A `discount` (e.g. a coupon) lowers the price of billed periods: every discounted period gets a
negative line with the `label` next to its usage on the same bill, until the discount ends.
```php
$subscription = $user->subscriptions()->create([
'name' => 'Pixel Stream Pass',
'unit' => 'month',
'price' => 6.714286,
'discount' => [
'label' => 'SUMMER20', // shown to the user, e.g. the coupon code
'percent_off' => 20, // or 'amount_off' => 1.50 (net, per period)
'duration' => 'repeating', // once, repeating or forever
'duration_in_periods' => 3, // only for repeating
],
]);
$subscription->discount; // {label, percent_off, amount_off, duration, duration_in_periods, periods_remaining}
```
A period is one unit of the subscription (a month for monthly prices). A free trial isn't a
billed period: the discount starts with the first paid one. `periods_remaining` counts down with
every billed period (null for `forever`, 0 once it ended); then `subscription.discount_ended`
(`EventType::SUBSCRIPTION_DISCOUNT_ENDED`) is sent. A discount can't be combined with a
`monthly_cap`. It is set when the subscription is created (app subscriptions and checkout
sessions alike) and changed afterwards with
[`change()`](#changing-running-subscriptions-trusted-apps). A resize (`predecessor_id`) doesn't
carry it over: send the successor the rest (`'duration' => 'repeating'`,
`'duration_in_periods' => $old->discount->periods_remaining`).
### Webhooks ### Webhooks
@@ -415,6 +509,25 @@ arrive more than once or out of order: use `$event['id']` to skip duplicates, an
object through the API when the current state matters. The admin panel can send a object through the API when the current state matters. The admin panel can send a
`webhook.test` event to check your endpoint. `webhook.test` event to check your endpoint.
`subscription.renewed` (`EventType::SUBSCRIPTION_RENEWED`) is sent once for every period booked
on the user's monthly order, **the first one included**, with `$event['data']['period']`:
`{subscription_id, period_start, period_end, periods, first_period, unit, order_id, order_item_id,
items, currency_iso, net_amount}` (`net_amount` after a discount). `first_period` is `true` for the
subscription's first paid period (the checkout, sent after `subscription.activated`, or the first
period after a free trial) and `false` for every later one (renewals, resuming an expired
subscription, a unit change applied right away). Hourly subscriptions renew every hour, so this
event is **opt-in**: select it explicitly for your endpoint, `*` doesn't include it.
```php
if ($event['type'] === EventType::SUBSCRIPTION_RENEWED) {
$period = $event['data']['period'];
// e.g. a commission per paid period; the first one may count differently
Commission::record($period['subscription_id'], $period['net_amount'], first: $period['first_period']);
}
```
`order.*` events list the order's `items` and `invoice` like
[app orders](#app-orders-trusted-apps-no-user-token).
### Errors ### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
@@ -508,7 +621,8 @@ subscriptions it created (plus those of clients delegated to it).
Failures throw `Anikeen\Id\Exceptions\ApiException` with `status` (0 = not reachable), a stable Failures throw `Anikeen\Id\Exceptions\ApiException` with `status` (0 = not reachable), a stable
`error` code (`already_active`, `already_cancelled`, `not_active`, `already_paused`, `error` code (`already_active`, `already_cancelled`, `not_active`, `already_paused`,
`already_resumed`, `billing_address_missing`, `not_changeable`, `subscription_not_found`, `already_resumed`, `billing_address_missing`, `not_changeable`, `subscription_not_found`,
`idempotency_key_conflict`) and `isNotFound()`, `isConflict()`, `isTemporary()`: `idempotency_key_conflict`, `in_checkout_session`, `trial_already_used`, `proration_not_possible`,
`credit_exceeds_bill`) and `isNotFound()`, `isConflict()`, `isTemporary()`:
```php ```php
try { try {
@@ -524,6 +638,70 @@ try {
} }
``` ```
### Confirmable orders (trusted apps)
Trusted apps can charge one-off orders right away (`'checkout' => true`). With `'confirm' => true`
the user confirms and pays the order on Anikeen ID instead, like for a third-party app – e.g. a
top-up the user starts in your app: Anikeen ID shows the order with VAT, the billing address and
the payment method (one can be added there) and takes the payment.
```php
$order = app(AnikeenId::class)->createOrderForUser($team->owner->anikeen_id, [
'group' => $team->id,
'confirm' => true,
'return_url' => route('billing.topup.done'), // same origin as one of your redirect URIs
'metadata' => ['topup' => $topup->id], // returned as is, also in the order webhooks
'items' => [['type' => 'digital', 'name' => 'Credit', 'unit' => 'onetime', 'units' => 1, 'gross_price' => '10.00']],
])->data;
return redirect()->away($order->confirmation_url);
// later: https://console.test/billing/topup/done?status=completed&order=01K…
```
`confirm` needs `items` and can't be combined with `checkout` or a `transaction` (422). The draft
expires after 24 hours (`expires_at`): it is cancelled, `confirmation_url` becomes null and
`checkout()` answers 409 `confirmation_expired`. Follow the order through its webhooks:
`order.created`, then `order.completed` once paid (`order.payment_failed` for a declined charge)
or `order.cancelled` when the user declines or it expires. `return_url` and `metadata` work for
every order, also with the user's token (`$user->orders()->create([...])`).
### Changing running subscriptions (trusted apps)
`update()` changes a subscription before its checkout (afterwards only the description).
`change()` changes price, unit, name, description or discount of a running subscription, like a
plan change (send only what changes):
```php
use Anikeen\Id\Resources\AppSubscription;
// upgrade now, prorated: the unused rest of the period is credited, the new price charged for it
$subscription->change(['apply' => AppSubscription::APPLY_NOW, 'price' => '0.0274', 'name' => 'Cloud Server M']);
// downgrade from the next period on
$subscription->change(['apply' => AppSubscription::APPLY_PERIOD_END, 'price' => '4.99']);
$subscription->scheduled_change; // {applies_at, name, description, unit, price, discount, created_at}
$subscription->cancelScheduledChange(); // keep the current values after all
```
- `now` credits the unused part of the current period at the rate it was billed at and charges
the rest at the new price, both on the user's open bill. A new `unit` starts a new, fully billed
period now. A downgrade whose credit would exceed the open bill is refused with 409
`credit_exceeds_bill`: schedule it for the period end instead. 409 `proration_not_possible` when
the current period wasn't billed to the subscription (e.g. a takeover); `period_end` works.
- `period_end` changes nothing now; the next period is billed at the new values. A new
`period_end` change replaces the scheduled one, `now` drops it.
- During a free trial nothing was billed: both just change what the trial ends in.
- `discount` replaces the discount from the next billed period on, `null` ends it. When the unit
changes, a discount that counts in periods or an `amount_off` has to be sent again (422).
- Subscriptions with a monthly cap change only with `period_end`.
- Only running subscriptions can change: 409 `not_changeable` for drafts (use `update()`), paused,
cancelling, expired and cancelled ones.
When a change takes effect (right away or at the period end), Anikeen ID sends
`subscription.updated` (`EventType::SUBSCRIPTION_UPDATED`) with the old values of the changed
fields in `$event['data']['previous_attributes']`. Telling the user about a price increase (and
getting consent where needed) is up to your app.
### App orders (trusted apps, no user token) ### App orders (trusted apps, no user token)
Orders that bill the app's subscriptions, read with the app's token (scope `orders:client`): Orders that bill the app's subscriptions, read with the app's token (scope `orders:client`):
@@ -538,6 +716,67 @@ Each order has `status` (`processing` = current month, still collecting; `pendin
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null = paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
no more automatic retries) and `last_payment_error`. no more automatic retries) and `last_payment_error`.
Collective (monthly) orders are kept **per app**: your subscriptions are never billed on the same
order as another app's, even if both use the same `group` (or none); clients delegated to your app
bill on your app's orders. `$order->oauth_client_id` is the app that bills the order.
Orders also list their lines and their invoice, e.g. to show a team's invoices or to attribute
amounts (commissions) per subscription:
```php
$order = app(AnikeenId::class)->appOrders()->find($orderId);
foreach ($order->items as $item) {
$item->subscription_id; // null for lines that bill no subscription
$item->period_start; $item->period_end; // ISO 8601, the end is exclusive; null without a period
$item->net_amount; // units × price, rounded to cents
$item->discount; // {label, percent_off, amount_off} on a discount line, else null
}
$order->invoice; // null until paid, then {id, number, status, …, pdf_url}
$pdf = app(AnikeenId::class)->appOrders()->invoicePdf($orderId); // the invoice PDF (binary)
```
The net amount actually billed for a subscription is the sum of `net_amount` of its lines
(discount lines are negative). `invoicePdf()` throws `ApiException` with 404 `invoice_not_found`
before the order is paid and 409 `invoice_not_ready` while the PDF is still being rendered
(usually seconds after the payment; try again later). Legacy orders from before collective orders
were kept per app (`oauth_client_id` null) can also bill other apps' subscriptions: there `items`
lists only your lines (the totals are the whole order's), `invoice->pdf_url` is null and
`invoicePdf()` throws `ApiException` with 403 `invoice_includes_other_apps`.
### Billing portal links (trusted apps)
A "Manage billing" button for a user whose token your app doesn't have (e.g. the owner of a team
your app bills): create a link to the user's billing pages on Anikeen ID with the app's token
(scope `subscriptions:client` or `orders:client`). Like Stripe's customer portal, it works
**without a login**:
```php
Route::post('teams/{team}/billing', function (Team $team) {
return redirect()->away($team->owner->appBillingPortalUrl([
'return_url' => route('teams.show', $team), // "Back to <app>"; same origin as one of your redirect URIs
'group' => $team->id, // only this group's subscriptions, orders and invoices
'flow' => 'invoices', // overview (default), payment_method, payment_method_update,
// invoices, subscriptions or orders
]));
});
app(AnikeenId::class)->createBillingPortalLinkForUser($userId, ['flow' => 'payment_method_update']);
// {object: "billing_portal_link", url, user_id, flow, group, return_url, expires_at}
```
The link is **single use** and valid for about 15 minutes: create it when the user clicks and
redirect right away; don't store, mail or reuse it (a second request with the same link, e.g. a
reload, gets a 403). Opening it starts a portal session for that user that needs no login and
signs nobody in: only the billing pages of the `flow`, only what bills your app's subscriptions
(of the `group`), with "Back to *app*". The user's account settings, other apps and OAuth consent
stay behind the normal login; signed in as another account, the portal still shows only the
link's user. The session ends after an hour without activity or on "Back to *app*".
`payment_method_update` leads straight to replacing the payment method (e.g. after a failed
payment) and back to `return_url`. For the signed-in user with their own token,
`$user->billingPortalUrl()` stays available.
### 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,
@@ -791,6 +1030,7 @@ public function shippingPriceList(): Result
```php ```php
public function appOrders(): AppOrders public function appOrders(): AppOrders
public function appCheckoutSessions(): AppCheckoutSessions public function appCheckoutSessions(): AppCheckoutSessions
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
``` ```
@@ -896,6 +1136,7 @@ public function appSubscriptions(): AppSubscriptions
public function appCheckoutSessions(): AppCheckoutSessions public function appCheckoutSessions(): AppCheckoutSessions
public function checkoutSessions(): CheckoutSessions public function checkoutSessions(): CheckoutSessions
public function appOrders(): AppOrders public function appOrders(): AppOrders
public function appBillingPortalUrl(array $attributes = []): string
public function setBillable(Billable|Model $billable): self public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable public function getBillable(): Billable
``` ```
+244 -5
View File
@@ -4,7 +4,7 @@
[![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
[![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
PHP Anikeen ID API Client for Laravel 11+ PHP Anikeen ID API Client for Laravel 12+
Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders, Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders,
subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID. subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID.
@@ -71,7 +71,7 @@ Add to `config/services.php` file:
### Event Listener ### Event Listener
In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method: Since Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
```php ```php
public function boot(): void public function boot(): void
@@ -361,8 +361,102 @@ same value as `status`):
| `expired` | Paused and ran out; `resume()` starts a new period | no | | `expired` | Paused and ran out; `resume()` starts a new period | no |
| `cancelled` | Cancelled for good | no | | `cancelled` | Cancelled for good | no |
There are shortcuts for each: `isDraft()`, `isPaused()`, `isCancelling()`, `isExpired()`, There are shortcuts for each: `isDraft()`, `isTrial()`, `isPaused()`, `isCancelling()`,
`isCancelled()`. App subscriptions (`appSubscriptions()`) have the same methods. `isExpired()`, `isCancelled()`. App subscriptions (`appSubscriptions()`) have the same methods.
#### Free trials
Give a subscription a free trial with `trial_days` (1–365, counted from the user's confirmation or
the checkout) or a fixed `trial_ends_at` (ISO 8601, in the future), not both. The subscription
starts right away with `status` `trial` and nothing billed; the first period is billed when the
trial ends. `trial_once` refuses the trial if the user already had one with your app in the same
`group` (422 when creating, 409 `trial_already_used` when checking out):
```php
$subscription = $user->subscriptions()->create([
'name' => 'Pixel Stream Pass',
'unit' => 'month',
'price' => 6.714286,
'trial_days' => 14,
'trial_once' => true,
]);
```
A few days before the trial ends, Anikeen ID sends `subscription.trial_will_end`
(`EventType::SUBSCRIPTION_TRIAL_WILL_END`), e.g. to remind the user; `data.payment_method_missing`
tells you whether the user still has to add a payment method. Cancelling during the trial
bills nothing. Trials work the same for app subscriptions and per subscription of a checkout
session.
By default the user needs a payment method to confirm a trial. With `trial_payment_method`
`TrialPaymentMethod::OPTIONAL` (only together with a trial; `TrialPaymentMethod::REQUIRED` is the
default) the user can confirm it without one, as long as nothing else has to be paid: in a
checkout session every subscription has to be such a trial. The confirmation page tells the user
to add a payment method before the trial ends. If there is still none then, the subscription is
**cancelled** at the trial's end instead of billed: `status` `cancelled`, `cancellation_reason`
`CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD`, `subscription.cancelled` with
`data.reason` set to the same value, and no `subscription.renewed`. Adding a payment method later
doesn't bring it back – create a new subscription. The trial still counts for `trial_once`.
Anikeen ID also mails the user a few days before such a trial ends without a payment method.
```php
use Anikeen\Id\Enums\CancellationReason;
use Anikeen\Id\Enums\TrialPaymentMethod;
use Anikeen\Id\Webhooks\EventType;
$subscription = $user->appSubscriptions()->create([
'group' => $teamId,
'name' => 'Uptimr Pro',
'unit' => 'month',
'price' => 19.00,
'trial_days' => 14,
'trial_payment_method' => TrialPaymentMethod::OPTIONAL,
]);
// in your webhook handler
$data = $event['data'];
match ($event['type']) {
EventType::SUBSCRIPTION_TRIAL_WILL_END => $data['payment_method_missing']
? Reminder::addPaymentMethod($data['object']['id'])
: null,
EventType::SUBSCRIPTION_CANCELLED => $data['reason'] === CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD
? Pass::downgrade($data['object']['id'])
: Pass::revoke($data['object']['id']),
default => null,
};
```
#### Discounts
A `discount` (e.g. a coupon) lowers the price of billed periods: every discounted period gets a
negative line with the `label` next to its usage on the same bill, until the discount ends.
```php
$subscription = $user->subscriptions()->create([
'name' => 'Pixel Stream Pass',
'unit' => 'month',
'price' => 6.714286,
'discount' => [
'label' => 'SUMMER20', // shown to the user, e.g. the coupon code
'percent_off' => 20, // or 'amount_off' => 1.50 (net, per period)
'duration' => 'repeating', // once, repeating or forever
'duration_in_periods' => 3, // only for repeating
],
]);
$subscription->discount; // {label, percent_off, amount_off, duration, duration_in_periods, periods_remaining}
```
A period is one unit of the subscription (a month for monthly prices). A free trial isn't a
billed period: the discount starts with the first paid one. `periods_remaining` counts down with
every billed period (null for `forever`, 0 once it ended); then `subscription.discount_ended`
(`EventType::SUBSCRIPTION_DISCOUNT_ENDED`) is sent. A discount can't be combined with a
`monthly_cap`. It is set when the subscription is created (app subscriptions and checkout
sessions alike) and changed afterwards with
[`change()`](#changing-running-subscriptions-trusted-apps). A resize (`predecessor_id`) doesn't
carry it over: send the successor the rest (`'duration' => 'repeating'`,
`'duration_in_periods' => $old->discount->periods_remaining`).
### Webhooks ### Webhooks
@@ -415,6 +509,25 @@ arrive more than once or out of order: use `$event['id']` to skip duplicates, an
object through the API when the current state matters. The admin panel can send a object through the API when the current state matters. The admin panel can send a
`webhook.test` event to check your endpoint. `webhook.test` event to check your endpoint.
`subscription.renewed` (`EventType::SUBSCRIPTION_RENEWED`) is sent once for every period booked
on the user's monthly order, **the first one included**, with `$event['data']['period']`:
`{subscription_id, period_start, period_end, periods, first_period, unit, order_id, order_item_id,
items, currency_iso, net_amount}` (`net_amount` after a discount). `first_period` is `true` for the
subscription's first paid period (the checkout, sent after `subscription.activated`, or the first
period after a free trial) and `false` for every later one (renewals, resuming an expired
subscription, a unit change applied right away). Hourly subscriptions renew every hour, so this
event is **opt-in**: select it explicitly for your endpoint, `*` doesn't include it.
```php
if ($event['type'] === EventType::SUBSCRIPTION_RENEWED) {
$period = $event['data']['period'];
// e.g. a commission per paid period; the first one may count differently
Commission::record($period['subscription_id'], $period['net_amount'], first: $period['first_period']);
}
```
`order.*` events list the order's `items` and `invoice` like
[app orders](#app-orders-trusted-apps-no-user-token).
### Errors ### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
@@ -508,7 +621,8 @@ subscriptions it created (plus those of clients delegated to it).
Failures throw `Anikeen\Id\Exceptions\ApiException` with `status` (0 = not reachable), a stable Failures throw `Anikeen\Id\Exceptions\ApiException` with `status` (0 = not reachable), a stable
`error` code (`already_active`, `already_cancelled`, `not_active`, `already_paused`, `error` code (`already_active`, `already_cancelled`, `not_active`, `already_paused`,
`already_resumed`, `billing_address_missing`, `not_changeable`, `subscription_not_found`, `already_resumed`, `billing_address_missing`, `not_changeable`, `subscription_not_found`,
`idempotency_key_conflict`) and `isNotFound()`, `isConflict()`, `isTemporary()`: `idempotency_key_conflict`, `in_checkout_session`, `trial_already_used`, `proration_not_possible`,
`credit_exceeds_bill`) and `isNotFound()`, `isConflict()`, `isTemporary()`:
```php ```php
try { try {
@@ -524,6 +638,70 @@ try {
} }
``` ```
### Confirmable orders (trusted apps)
Trusted apps can charge one-off orders right away (`'checkout' => true`). With `'confirm' => true`
the user confirms and pays the order on Anikeen ID instead, like for a third-party app – e.g. a
top-up the user starts in your app: Anikeen ID shows the order with VAT, the billing address and
the payment method (one can be added there) and takes the payment.
```php
$order = app(AnikeenId::class)->createOrderForUser($team->owner->anikeen_id, [
'group' => $team->id,
'confirm' => true,
'return_url' => route('billing.topup.done'), // same origin as one of your redirect URIs
'metadata' => ['topup' => $topup->id], // returned as is, also in the order webhooks
'items' => [['type' => 'digital', 'name' => 'Credit', 'unit' => 'onetime', 'units' => 1, 'gross_price' => '10.00']],
])->data;
return redirect()->away($order->confirmation_url);
// later: https://console.test/billing/topup/done?status=completed&order=01K…
```
`confirm` needs `items` and can't be combined with `checkout` or a `transaction` (422). The draft
expires after 24 hours (`expires_at`): it is cancelled, `confirmation_url` becomes null and
`checkout()` answers 409 `confirmation_expired`. Follow the order through its webhooks:
`order.created`, then `order.completed` once paid (`order.payment_failed` for a declined charge)
or `order.cancelled` when the user declines or it expires. `return_url` and `metadata` work for
every order, also with the user's token (`$user->orders()->create([...])`).
### Changing running subscriptions (trusted apps)
`update()` changes a subscription before its checkout (afterwards only the description).
`change()` changes price, unit, name, description or discount of a running subscription, like a
plan change (send only what changes):
```php
use Anikeen\Id\Resources\AppSubscription;
// upgrade now, prorated: the unused rest of the period is credited, the new price charged for it
$subscription->change(['apply' => AppSubscription::APPLY_NOW, 'price' => '0.0274', 'name' => 'Cloud Server M']);
// downgrade from the next period on
$subscription->change(['apply' => AppSubscription::APPLY_PERIOD_END, 'price' => '4.99']);
$subscription->scheduled_change; // {applies_at, name, description, unit, price, discount, created_at}
$subscription->cancelScheduledChange(); // keep the current values after all
```
- `now` credits the unused part of the current period at the rate it was billed at and charges
the rest at the new price, both on the user's open bill. A new `unit` starts a new, fully billed
period now. A downgrade whose credit would exceed the open bill is refused with 409
`credit_exceeds_bill`: schedule it for the period end instead. 409 `proration_not_possible` when
the current period wasn't billed to the subscription (e.g. a takeover); `period_end` works.
- `period_end` changes nothing now; the next period is billed at the new values. A new
`period_end` change replaces the scheduled one, `now` drops it.
- During a free trial nothing was billed: both just change what the trial ends in.
- `discount` replaces the discount from the next billed period on, `null` ends it. When the unit
changes, a discount that counts in periods or an `amount_off` has to be sent again (422).
- Subscriptions with a monthly cap change only with `period_end`.
- Only running subscriptions can change: 409 `not_changeable` for drafts (use `update()`), paused,
cancelling, expired and cancelled ones.
When a change takes effect (right away or at the period end), Anikeen ID sends
`subscription.updated` (`EventType::SUBSCRIPTION_UPDATED`) with the old values of the changed
fields in `$event['data']['previous_attributes']`. Telling the user about a price increase (and
getting consent where needed) is up to your app.
### App orders (trusted apps, no user token) ### App orders (trusted apps, no user token)
Orders that bill the app's subscriptions, read with the app's token (scope `orders:client`): Orders that bill the app's subscriptions, read with the app's token (scope `orders:client`):
@@ -538,6 +716,67 @@ Each order has `status` (`processing` = current month, still collecting; `pendin
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null = paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
no more automatic retries) and `last_payment_error`. no more automatic retries) and `last_payment_error`.
Collective (monthly) orders are kept **per app**: your subscriptions are never billed on the same
order as another app's, even if both use the same `group` (or none); clients delegated to your app
bill on your app's orders. `$order->oauth_client_id` is the app that bills the order.
Orders also list their lines and their invoice, e.g. to show a team's invoices or to attribute
amounts (commissions) per subscription:
```php
$order = app(AnikeenId::class)->appOrders()->find($orderId);
foreach ($order->items as $item) {
$item->subscription_id; // null for lines that bill no subscription
$item->period_start; $item->period_end; // ISO 8601, the end is exclusive; null without a period
$item->net_amount; // units × price, rounded to cents
$item->discount; // {label, percent_off, amount_off} on a discount line, else null
}
$order->invoice; // null until paid, then {id, number, status, …, pdf_url}
$pdf = app(AnikeenId::class)->appOrders()->invoicePdf($orderId); // the invoice PDF (binary)
```
The net amount actually billed for a subscription is the sum of `net_amount` of its lines
(discount lines are negative). `invoicePdf()` throws `ApiException` with 404 `invoice_not_found`
before the order is paid and 409 `invoice_not_ready` while the PDF is still being rendered
(usually seconds after the payment; try again later). Legacy orders from before collective orders
were kept per app (`oauth_client_id` null) can also bill other apps' subscriptions: there `items`
lists only your lines (the totals are the whole order's), `invoice->pdf_url` is null and
`invoicePdf()` throws `ApiException` with 403 `invoice_includes_other_apps`.
### Billing portal links (trusted apps)
A "Manage billing" button for a user whose token your app doesn't have (e.g. the owner of a team
your app bills): create a link to the user's billing pages on Anikeen ID with the app's token
(scope `subscriptions:client` or `orders:client`). Like Stripe's customer portal, it works
**without a login**:
```php
Route::post('teams/{team}/billing', function (Team $team) {
return redirect()->away($team->owner->appBillingPortalUrl([
'return_url' => route('teams.show', $team), // "Back to <app>"; same origin as one of your redirect URIs
'group' => $team->id, // only this group's subscriptions, orders and invoices
'flow' => 'invoices', // overview (default), payment_method, payment_method_update,
// invoices, subscriptions or orders
]));
});
app(AnikeenId::class)->createBillingPortalLinkForUser($userId, ['flow' => 'payment_method_update']);
// {object: "billing_portal_link", url, user_id, flow, group, return_url, expires_at}
```
The link is **single use** and valid for about 15 minutes: create it when the user clicks and
redirect right away; don't store, mail or reuse it (a second request with the same link, e.g. a
reload, gets a 403). Opening it starts a portal session for that user that needs no login and
signs nobody in: only the billing pages of the `flow`, only what bills your app's subscriptions
(of the `group`), with "Back to *app*". The user's account settings, other apps and OAuth consent
stay behind the normal login; signed in as another account, the portal still shows only the
link's user. The session ends after an hour without activity or on "Back to *app*".
`payment_method_update` leads straight to replacing the payment method (e.g. after a failed
payment) and back to `return_url`. For the signed-in user with their own token,
`$user->billingPortalUrl()` stays available.
### 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,
+5 -5
View File
@@ -15,15 +15,15 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"ext-json": "*", "ext-json": "*",
"illuminate/support": "^11.0|^12.0|^13.0", "illuminate/support": "^12.69|^13.0",
"illuminate/console": "^11.0|^12.0|^13.0", "illuminate/console": "^12.69|^13.0",
"guzzlehttp/guzzle": "^7.8", "guzzlehttp/guzzle": "^7.8",
"socialiteproviders/manager": "^4.6", "socialiteproviders/manager": "^4.6",
"firebase/php-jwt": "^6.10|^7.0" "firebase/php-jwt": "^7.0"
}, },
"require-dev": { "require-dev": {
"laravel/framework": "^11.0|^12.0|^13.0", "laravel/framework": "^12.69|^13.0",
"orchestra/testbench": "^9.0|^10.0|^11.0", "orchestra/testbench": "^10.0|^11.0",
"phpunit/phpunit": "^11.0|^12.0" "phpunit/phpunit": "^11.0|^12.0"
}, },
"autoload": { "autoload": {
@@ -48,6 +48,14 @@ trait HasSubscriptionStatus
return in_array($this->status(), [SubscriptionStatus::TRIAL, SubscriptionStatus::ACTIVE, SubscriptionStatus::PAUSED, SubscriptionStatus::CANCELLING], true); return in_array($this->status(), [SubscriptionStatus::TRIAL, SubscriptionStatus::ACTIVE, SubscriptionStatus::PAUSED, SubscriptionStatus::CANCELLING], true);
} }
/**
* In its free trial: running, nothing billed until trial_ends_at.
*/
public function isTrial(): bool
{
return $this->status() === SubscriptionStatus::TRIAL;
}
/** /**
* Paused: runs until the paid period ends, then expires (resume() to continue). * Paused: runs until the paid period ends, then expires (resume() to continue).
*/ */
+18
View File
@@ -2,6 +2,8 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCheckoutSessions; use Anikeen\Id\Resources\AppCheckoutSessions;
use Anikeen\Id\Resources\AppOrders; use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Resources\AppSubscriptions; use Anikeen\Id\Resources\AppSubscriptions;
@@ -65,4 +67,20 @@ trait ManagesSubscriptions
{ {
return new AppOrders($this); return new AppOrders($this);
} }
/**
* A link to the user's billing pages on Anikeen ID that needs no login, created with the app's
* own token (trusted apps only, scope subscriptions:client or orders:client), e.g. for a
* "Manage billing" button; see AnikeenId::createBillingPortalLinkForUser() for the attributes
* (return_url, group, flow). Single use and valid for about 15 minutes: create it when the
* user clicks and redirect right away.
*
* @param array{return_url?: null|string, group?: null|string, flow?: null|string} $attributes
*
* @throws ApiException
*/
public function appBillingPortalUrl(array $attributes = []): string
{
return app(AnikeenId::class)->createBillingPortalLinkForUser($this->appSubscriptions()->userId(), $attributes)->url;
}
} }
+47 -1
View File
@@ -2,9 +2,11 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCheckoutSessions; use Anikeen\Id\Resources\AppCheckoutSessions;
use Anikeen\Id\Resources\AppOrders; use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use Anikeen\Id\Support\AppRequest;
use Throwable; use Throwable;
/** /**
@@ -29,11 +31,48 @@ trait ManagesUserBilling
return new AppCheckoutSessions(); return new AppCheckoutSessions();
} }
/**
* A link to a user's billing pages on Anikeen ID that needs no login, like Stripe's customer
* portal, e.g. for a "Manage billing" button of an app that bills another user's account (a
* team owner) without that user's token (trusted apps, scope subscriptions:client or
* orders:client). The link is single use and valid for about 15 minutes: create it when the
* user clicks and redirect right away, don't store it. It opens a portal session for that user
* only (no login, nobody signed in) with only the flow's billing pages and only what bills the
* app's subscriptions (of the group), leading back to return_url; it ends after an hour
* without activity.
*
* @param string $userId The user's Anikeen ID.
* @param array{
* return_url?: null|string,
* group?: null|string,
* flow?: null|string
* } $attributes return_url: "Back to the app" (same origin as one of the app's redirect URIs,
* 422 otherwise).
* group: only this group's subscriptions and the orders, invoices and
* transactions billing them.
* flow: overview (default), payment_method, payment_method_update (straight to
* replacing the payment method, e.g. after a failed payment), invoices,
* subscriptions or orders.
* @return object {object: "billing_portal_link", url, user_id, flow, group, return_url, expires_at}
*
* @throws ApiException 403 untrusted app or missing scope, 404 unknown user, 422 validation
*/
public function createBillingPortalLinkForUser(string $userId, array $attributes = []): object
{
return AppRequest::send('POST', sprintf('v1/client/users/%s/billing-portal', $userId), $attributes)->data;
}
/** /**
* Create an order for a user (VAT from the billing address). With `checkout`, it is paid right * Create an order for a user (VAT from the billing address). With `checkout`, it is paid right
* away: with the given `transaction` (a payment captured elsewhere) or by charging the user's * away: with the given `transaction` (a payment captured elsewhere) or by charging the user's
* default payment method (402 when declined; the order stays pending and is retried). * default payment method (402 when declined; the order stays pending and is retried).
* *
* With `confirm`, the user confirms and pays it on Anikeen ID instead (e.g. a top-up): the
* order stays a draft with `confirmation_url` (send the user there) and is cancelled if not
* confirmed within 24 hours (`expires_at`; afterwards checking it out answers 409
* "confirmation_expired"). Webhooks: order.created, order.completed once paid, or
* order.cancelled when the user declines or it expires.
*
* @param string $userId The user's Anikeen ID. * @param string $userId The user's Anikeen ID.
* @param array{ * @param array{
* group?: string, * group?: string,
@@ -43,11 +82,18 @@ trait ManagesUserBilling
* shipping_address?: array, * shipping_address?: array,
* items: array<array{type: string, name: string, description?: string, image_url?: string, price?: float|string, gross_price?: float|string, unit: string, units: int}>, * items: array<array{type: string, name: string, description?: string, image_url?: string, price?: float|string, gross_price?: float|string, unit: string, units: int}>,
* checkout?: bool, * checkout?: bool,
* confirm?: bool,
* return_url?: null|string,
* metadata?: null|array,
* transaction_id?: string, * transaction_id?: string,
* transaction?: array, * transaction?: array,
* invoice_date?: string, * invoice_date?: string,
* currency_iso?: string * currency_iso?: string
* } $attributes * } $attributes confirm: needs items, can't be combined with checkout or a transaction.
* return_url: where the user returns after confirming, with `status` and
* `order` (same origin as one of the app's redirect URIs, 422 otherwise).
* metadata: the app's own data (max. 50 keys), returned as is in the API and
* the order webhooks.
* @throws Throwable * @throws Throwable
*/ */
public function createOrderForUser(string $userId, array $attributes): Result public function createOrderForUser(string $userId, array $attributes): Result
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Anikeen\Id\Enums;
/**
* `cancellation_reason` of a subscription (and `data.reason` of `subscription.cancelled`): why
* Anikeen ID's billing ended it. Null when the user, the app or an admin cancelled it.
*/
class CancellationReason
{
/** Its free trial (`trial_payment_method` optional) ended without a payment method: cancelled at the trial's end, nothing billed. */
const TRIAL_ENDED_WITHOUT_PAYMENT_METHOD = 'trial_ended_without_payment_method';
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Anikeen\Id\Enums;
/**
* `trial_payment_method` of a subscription with a free trial: whether the user needs a payment
* method to confirm it. Null on subscriptions without a trial.
*/
class TrialPaymentMethod
{
/** A payment method is needed to confirm the trial (the default). */
const REQUIRED = 'required';
/**
* The trial can be confirmed without a payment method (if nothing else has to be paid). If the
* user still has none when the trial ends, the subscription is cancelled then instead of billed
* (`cancellation_reason` CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD).
*/
const OPTIONAL = 'optional';
}
+11 -1
View File
@@ -36,7 +36,12 @@ class AppCheckoutSessions
* predecessor_id?: null|string, * predecessor_id?: null|string,
* vat_rate?: float, * vat_rate?: float,
* payload?: null|array, * payload?: null|array,
* ends_at?: null|string * ends_at?: null|string,
* trial_days?: null|int,
* trial_ends_at?: null|string,
* trial_once?: bool,
* trial_payment_method?: null|string,
* discount?: null|array{label: string, percent_off?: float|string, amount_off?: float|string, duration: string, duration_in_periods?: int}
* }>, * }>,
* return_url?: null|string, * return_url?: null|string,
* metadata?: null|array, * metadata?: null|array,
@@ -45,6 +50,11 @@ class AppCheckoutSessions
* } $attributes return_url must share scheme, host and port with one of the app's redirect * } $attributes return_url must share scheme, host and port with one of the app's redirect
* URIs; the user comes back to it with `status` and `checkout_session`. A * URIs; the user comes back to it with `status` and `checkout_session`. A
* repeated idempotency_key returns the first session instead of a new one. * repeated idempotency_key returns the first session instead of a new one.
* trial_days / trial_ends_at give a subscription a free trial from the user's
* confirmation on; trial_once is allowed on one subscription per group.
* trial_payment_method `optional`: the session can be confirmed without a
* payment method only if all of its subscriptions are such trials.
* discount: see AppSubscriptions::create(); the confirmation page shows it.
* *
* @throws ApiException 422 validation (e.g. return_url), 409 "idempotency_key_conflict" * @throws ApiException 422 validation (e.g. return_url), 409 "idempotency_key_conflict"
*/ */
+32 -3
View File
@@ -13,9 +13,22 @@ use Illuminate\Database\Eloquent\Model;
* scope orders:client) - of one user ($user->appOrders()) or of all users * scope orders:client) - of one user ($user->appOrders()) or of all users
* (app(AnikeenId::class)->appOrders()). Nothing is requested until a method is called. * (app(AnikeenId::class)->appOrders()). Nothing is requested until a method is called.
* *
* Each order is an object with id, user_id, group, status (processing|pending|completed|cancelled), * Each order is an object with id, user_id, oauth_client_id (the app that bills it: collective
* is_collective, net_total, vat_total, gross_total, currency_iso, created_at, paid_at, * orders are kept per app, even for the same group; null on legacy orders that billed several
* payment_attempts, next_payment_attempt_at and last_payment_error ({code, message} or null). * apps), group, metadata, status (processing|pending|completed|cancelled), is_collective,
* net_total, vat_total, gross_total, currency_iso, created_at, paid_at, payment_attempts,
* next_payment_attempt_at, last_payment_error ({code, message} or null), and
*
* - items: the order's lines of the app's subscriptions (all lines, except on legacy orders that
* also billed other apps), each {id, name, description, type, image_url, units, unit,
* price, vat_rate, subscription_id, meter, monthly_cap, discount, period_start, period_end,
* net_amount, vat_amount, gross_amount}. period_end is exclusive (the start of the next
* period); both are null for lines without a period. discount ({label, percent_off,
* amount_off}) marks the negative line of a subscription's discount, else null. The net amount
* a subscription was billed is the sum of net_amount of its lines.
* - invoice: null until the order is paid, then {id, number, type, status (pending while the PDF
* is being rendered, then completed), service_period_from, service_period_to, created_at,
* pdf_url}; see invoicePdf().
*/ */
class AppOrders class AppOrders
{ {
@@ -81,6 +94,22 @@ class AppOrders
return !$this->billable || $order->user_id === $this->userId() ? $order : null; return !$this->billable || $order->user_id === $this->userId() ? $order : null;
} }
/**
* The PDF of an order's invoice (binary string, with the embedded e-invoice), the same as its
* `invoice.pdf_url` with the app's token.
*
* @throws ApiException 404 "order_not_found", 404 "invoice_not_found" (not paid yet),
* 409 "invoice_not_ready" (still being rendered, try again later)
*/
public function invoicePdf(string $orderId): string
{
if ($this->billable && !$this->find($orderId)) {
throw new ApiException('Order not found.', 404, 'order_not_found');
}
return (string) AppRequest::send('GET', sprintf('v1/client/orders/%s/invoice', $orderId))->response()?->getBody();
}
private function userId(): string private function userId(): string
{ {
return (new AppSubscriptions($this->billable))->userId(); return (new AppSubscriptions($this->billable))->userId();
+56
View File
@@ -28,6 +28,9 @@ use JsonSerializable;
* @property ?string $status draft, trial, active, paused, cancelling, expired or cancelled, see status() * @property ?string $status draft, trial, active, paused, cancelling, expired or cancelled, see status()
* @property ?string $cancels_at when a scheduled cancellation takes effect * @property ?string $cancels_at when a scheduled cancellation takes effect
* @property ?string $trial_ends_at * @property ?string $trial_ends_at
* @property ?string $trial_payment_method required or optional with a trial (TrialPaymentMethod), null without
* @property ?string $cancellation_reason why the billing cancelled it (CancellationReason), null otherwise
* @property ?object $discount {label, percent_off, amount_off, duration, duration_in_periods, periods_remaining}; periods_remaining is null for `forever`, 0 once it ended
* @property ?string $starts_at * @property ?string $starts_at
* @property ?string $ends_at * @property ?string $ends_at
* @property ?string $accounting_at * @property ?string $accounting_at
@@ -36,11 +39,18 @@ use JsonSerializable;
* @property ?array $payload * @property ?array $payload
* @property ?string $idempotency_key * @property ?string $idempotency_key
* @property ?string $checkout_session_id the checkout session the user confirms it in, if any * @property ?string $checkout_session_id the checkout session the user confirms it in, if any
* @property ?object $scheduled_change {applies_at, name, description, unit, price, discount, created_at}: a change() for the period end
*/ */
class AppSubscription implements JsonSerializable class AppSubscription implements JsonSerializable
{ {
use HasSubscriptionStatus; use HasSubscriptionStatus;
/** change(): prorated, right away. */
public const APPLY_NOW = 'now';
/** change(): from the next period on (scheduled_change until then). */
public const APPLY_PERIOD_END = 'period_end';
public function __construct(protected object $data) public function __construct(protected object $data)
{ {
} }
@@ -59,6 +69,7 @@ class AppSubscription implements JsonSerializable
* Change a subscription (send only what changes). Before checkout everything can change; * Change a subscription (send only what changes). Before checkout everything can change;
* afterwards only the description, which names what the customer pays for and is shown * afterwards only the description, which names what the customer pays for and is shown
* on their bills, e.g. `$subscription->update(['description' => 'web-01.example.com'])`. * on their bills, e.g. `$subscription->update(['description' => 'web-01.example.com'])`.
* Prices of running subscriptions change with change().
* *
* @throws ApiException * @throws ApiException
*/ */
@@ -67,6 +78,51 @@ class AppSubscription implements JsonSerializable
return $this->send('PUT', '', $attributes); return $this->send('PUT', '', $attributes);
} }
/**
* Change a running subscription (send only what changes), like a plan change:
*
* - `apply: now`: prorated – the unused rest of the current period is credited at the rate it
* was billed at and charged at the new price on the user's open bill. A new unit starts a
* new, fully billed period now. A downgrade whose credit exceeds the open bill is refused
* (409 "credit_exceeds_bill"): schedule it for the period end instead.
* - `apply: period_end`: nothing changes now; the subscription has `scheduled_change` and the
* next period is billed at the new values. A new scheduled change replaces the previous one.
*
* During a free trial nothing was billed, so both just change what the trial ends in.
* Webhook `subscription.updated` (with `data.previous_attributes`) when the change takes effect.
*
* @param array{
* apply: string,
* price?: float|string,
* unit?: string,
* name?: string,
* description?: null|string,
* discount?: null|array{label: string, percent_off?: float|string, amount_off?: float|string, duration: string, duration_in_periods?: int}
* } $attributes apply: self::APPLY_NOW or self::APPLY_PERIOD_END.
* unit: a recurring unit (hour, day, week, month, year).
* discount: replaces the discount from the next billed period on, null ends it;
* required with a new unit when the current one counts in periods or is an
* amount_off. Subscriptions with a monthly cap change only at the period end.
*
* @throws ApiException 409 "not_changeable" (not running: draft, paused, cancelling, …),
* "proration_not_possible", "credit_exceeds_bill",
* "billing_address_missing"; 422 validation
*/
public function change(array $attributes): self
{
return $this->send('PUT', '/change', $attributes);
}
/**
* Drop the change scheduled for the period end (nothing happens without one).
*
* @throws ApiException
*/
public function cancelScheduledChange(): self
{
return $this->send('DELETE', '/change');
}
/** /**
* Activate the subscription and bill its first period. * Activate the subscription and bill its first period.
* *
+19 -1
View File
@@ -49,6 +49,11 @@ class AppSubscriptions
* vat_rate?: float, * vat_rate?: float,
* payload?: null|array, * payload?: null|array,
* ends_at?: null|string, * ends_at?: null|string,
* trial_days?: null|int,
* trial_ends_at?: null|string,
* trial_once?: bool,
* trial_payment_method?: null|string,
* discount?: null|array{label: string, percent_off?: float|string, amount_off?: float|string, duration: string, duration_in_periods?: int},
* idempotency_key?: null|string * idempotency_key?: null|string
* } $attributes With an idempotency_key (max. 191 characters), a retried call returns the * } $attributes With an idempotency_key (max. 191 characters), a retried call returns the
* subscription created by the first one instead of a duplicate. * subscription created by the first one instead of a duplicate.
@@ -56,8 +61,21 @@ class AppSubscriptions
* at most 2 decimals, not below price; null = no cap). * at most 2 decimals, not below price; null = no cap).
* predecessor_id: the subscription this one replaces (a resize; same user and * predecessor_id: the subscription this one replaces (a resize; same user and
* app, checked out, not cancelled): both share the monthly cap within a month. * app, checked out, not cancelled): both share the monthly cap within a month.
* trial_days: a free trial of 1–365 days from the checkout on (status `trial`,
* nothing billed; the first period is billed when it ends), or
* trial_ends_at: a fixed end of the trial (ISO 8601, in the future), not both.
* trial_once: refuse the trial if the user already had one with the app in the
* same group (422 here, 409 "trial_already_used" on checkout).
* trial_payment_method: TrialPaymentMethod::REQUIRED (default) or ::OPTIONAL,
* only with a trial: with `optional` the user may confirm it without a payment
* method, and without one at the trial's end it is cancelled instead of billed.
* discount: e.g. a coupon, as a negative line next to each billed period:
* label (shown to the user, max. 100 characters), percent_off (0.01–100) or
* amount_off (net per period, at most the price), duration `once` (the first
* billed period), `repeating` (duration_in_periods billed periods) or
* `forever`. Not combinable with a monthly_cap.
* *
* @throws ApiException * @throws ApiException 422 validation, 409 "idempotency_key_conflict", …
*/ */
public function create(array $attributes): AppSubscription public function create(array $attributes): AppSubscription
{ {
+15 -2
View File
@@ -8,7 +8,14 @@ use Throwable;
/** /**
* @property string $id * @property string $id
* @property null|string $confirmation_url where the user confirms it (third-party apps) * @property null|string $confirmation_url where the user confirms it (third-party apps, or `confirm`)
* @property null|string $oauth_client_id the app that bills the order: the app that created it, or for a monthly
* (collective) order the app whose subscriptions it bills; collective orders
* are kept per app. null for Anikeen's own (and legacy orders of several apps)
* @property null|string $group
* @property null|object $metadata the app's own data, returned as is
* @property null|string $return_url where the user returns after confirming
* @property null|string $expires_at when a draft created with `confirm` is cancelled unless confirmed
*/ */
class Order extends BaseResource class Order extends BaseResource
{ {
@@ -53,10 +60,14 @@ class Order extends BaseResource
* country_iso: string, * country_iso: string,
* phone_number: null|string, * phone_number: null|string,
* email: null|string * email: null|string
* } * },
* group?: null|string,
* metadata?: null|array
* } $attributes The order data: * } $attributes The order data:
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code) * - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address for physical items (first name, last name, address, postal code, city, ISO 3166-1 alpha-2 country code) * - shipping_address: Shipping address for physical items (first name, last name, address, postal code, city, ISO 3166-1 alpha-2 country code)
* - group: The app's group (optional)
* - metadata: The app's own data (max. 50 keys), only while the order is a draft (optional)
* @throws Throwable * @throws Throwable
*/ */
public function update(array $attributes = []): self public function update(array $attributes = []): self
@@ -69,6 +80,8 @@ class Order extends BaseResource
/** /**
* Checkout given order from the current user. * Checkout given order from the current user.
* *
* A draft created with `confirm` can't be checked out once it expired (409 "confirmation_expired").
*
* @throws Throwable * @throws Throwable
*/ */
public function checkout(): self public function checkout(): self
+8
View File
@@ -48,6 +48,10 @@ class Orders extends BaseCollection
* phone_number: null|string, * phone_number: null|string,
* email: null|string * email: null|string
* }, * },
* group?: null|string,
* confirm?: bool,
* return_url?: null|string,
* metadata?: null|array,
* items: array<array{ * items: array<array{
* type: string, * type: string,
* name: string, * name: string,
@@ -61,6 +65,10 @@ class Orders extends BaseCollection
* } $attributes The order data: * } $attributes The order data:
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code) * - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address for physical items (first name, last name, address, postal code, city, ISO 3166-1 alpha-2 country code) * - shipping_address: Shipping address for physical items (first name, last name, address, postal code, city, ISO 3166-1 alpha-2 country code)
* - group: The app's group, e.g. a team (optional)
* - confirm: Trusted apps: leave confirming and paying to the user (confirmation_url, expires after 24 hours; optional)
* - return_url: Where the user returns after confirming (same origin as one of the app's redirect URIs; optional)
* - metadata: The app's own data (max. 50 keys), returned as is in the API and webhooks (optional)
* - items: Array of order items: * - items: Array of order items:
* - type: digital, physical or physical-reduced (VAT category) * - type: digital, physical or physical-reduced (VAT category)
* - price/gross_price: net or gross unit price, one of both * - price/gross_price: net or gross unit price, one of both
+3
View File
@@ -27,6 +27,9 @@ use Throwable;
* @property string $ends_at * @property string $ends_at
* @property ?string $cancels_at when a scheduled cancellation takes effect * @property ?string $cancels_at when a scheduled cancellation takes effect
* @property ?string $trial_ends_at * @property ?string $trial_ends_at
* @property ?string $trial_payment_method required or optional with a trial (TrialPaymentMethod), null without
* @property ?string $cancellation_reason why the billing cancelled it (CancellationReason), null otherwise
* @property ?object $discount {label, percent_off, amount_off, duration, duration_in_periods, periods_remaining}; periods_remaining is null for `forever`, 0 once it ended
* @property null|string $confirmation_url where the user confirms it (third-party apps) * @property null|string $confirmation_url where the user confirms it (third-party apps)
*/ */
class Subscription extends BaseResource class Subscription extends BaseResource
+11 -1
View File
@@ -24,7 +24,12 @@ class Subscriptions extends BaseCollection
* predecessor_id?: null|string, * predecessor_id?: null|string,
* vat_rate: float, * vat_rate: float,
* payload: null|array, * payload: null|array,
* ends_at: null|string * ends_at: null|string,
* trial_days?: null|int,
* trial_ends_at?: null|string,
* trial_once?: bool,
* trial_payment_method?: null|string,
* discount?: null|array{label: string, percent_off?: float|string, amount_off?: float|string, duration: string, duration_in_periods?: int}
* } $attributes The subscription data: * } $attributes The subscription data:
* - group: The group (optional) * - group: The group (optional)
* - name: The name * - name: The name
@@ -36,6 +41,11 @@ class Subscriptions extends BaseCollection
* - vat_rate: The VAT rate (required when set) * - vat_rate: The VAT rate (required when set)
* - payload: The payload (optional) * - payload: The payload (optional)
* - ends_at: The end date (optional) * - ends_at: The end date (optional)
* - trial_days: A free trial of 1–365 days from the user's confirmation on (optional)
* - trial_ends_at: Or a fixed end of the free trial (ISO 8601, in the future; optional)
* - trial_once: Refuse the trial if the user already had one with the app in the group (optional)
* - trial_payment_method: TrialPaymentMethod::REQUIRED (default) or ::OPTIONAL, only with a trial (optional)
* - discount: A discount per billed period, see AppSubscriptions::create() (optional)
* @throws Throwable * @throws Throwable
*/ */
public function create(array $attributes): Subscription public function create(array $attributes): Subscription
+25
View File
@@ -18,9 +18,34 @@ final class EventType
public const SUBSCRIPTION_ACTIVATED = 'subscription.activated'; public const SUBSCRIPTION_ACTIVATED = 'subscription.activated';
public const SUBSCRIPTION_PAUSED = 'subscription.paused'; public const SUBSCRIPTION_PAUSED = 'subscription.paused';
public const SUBSCRIPTION_RESUMED = 'subscription.resumed'; public const SUBSCRIPTION_RESUMED = 'subscription.resumed';
/** `data.reason`: why the billing cancelled it (CancellationReason, e.g. a trial that ended without a payment method), else null. */
public const SUBSCRIPTION_CANCELLED = 'subscription.cancelled'; public const SUBSCRIPTION_CANCELLED = 'subscription.cancelled';
public const SUBSCRIPTION_EXPIRED = 'subscription.expired'; public const SUBSCRIPTION_EXPIRED = 'subscription.expired';
/**
* The free trial of a running subscription ends in a few days (see trial_ends_at); billing
* starts then. `data.payment_method_missing`: the user has no payment method yet (with
* trial_payment_method `optional` the subscription then ends with the trial).
*/
public const SUBSCRIPTION_TRIAL_WILL_END = 'subscription.trial_will_end';
/** The last discounted period of a subscription was billed; the following ones are billed at the full price. */
public const SUBSCRIPTION_DISCOUNT_ENDED = 'subscription.discount_ended';
/**
* The price, unit, name, description or discount of a running subscription changed (right away
* or at the period end); `data.previous_attributes` has the changed fields' old values.
*/
public const SUBSCRIPTION_UPDATED = 'subscription.updated';
/**
* A period of a subscription was booked on its collective order, the first one included;
* `data.period` describes it, `data.period.first_period` is true for the subscription's first
* paid period (checkout or the end of a free trial). Opt-in: only endpoints that select this
* event get it (not through `*`).
*/
public const SUBSCRIPTION_RENEWED = 'subscription.renewed';
/** The user confirmed a checkout session: all of its subscriptions are running (sent after their subscription.activated). */ /** The user confirmed a checkout session: all of its subscriptions are running (sent after their subscription.activated). */
public const CHECKOUT_COMPLETED = 'checkout.completed'; public const CHECKOUT_COMPLETED = 'checkout.completed';
+63
View File
@@ -4,6 +4,8 @@ namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId; use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable; use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use GuzzleHttp\Psr7\Response;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class AppOrdersTest extends TestCase class AppOrdersTest extends TestCase
@@ -68,4 +70,65 @@ class AppOrdersTest extends TestCase
$this->assertNull($orders->find('o1')); $this->assertNull($orders->find('o1'));
$this->assertSame('o1', $orders->find('o1')->id); $this->assertSame('o1', $orders->find('o1')->id);
} }
public function test_orders_list_their_lines_and_invoice(): void
{
$this->respond(200, ['data' => [
'id' => 'o1',
'user_id' => '01JUSER',
'group' => 'team-1',
'metadata' => ['topup' => '7'],
'items' => [
['id' => 'i1', 'subscription_id' => '01JSUB', 'units' => 1, 'price' => 10, 'period_start' => '2026-11-01T00:00:00+00:00', 'period_end' => '2026-12-01T00:00:00+00:00', 'net_amount' => 10, 'discount' => null],
['id' => 'i2', 'subscription_id' => '01JSUB', 'units' => 1, 'price' => -2, 'period_start' => '2026-11-01T00:00:00+00:00', 'period_end' => '2026-12-01T00:00:00+00:00', 'net_amount' => -2, 'discount' => ['label' => 'SUMMER20', 'percent_off' => 20, 'amount_off' => null]],
],
'invoice' => ['id' => 'inv1', 'number' => 'RE-2026-0001', 'status' => 'completed', 'pdf_url' => 'https://id.test/api/v1/client/orders/o1/invoice'],
]]);
$order = $this->owner()->appOrders()->find('o1');
$this->assertSame('7', $order->metadata->topup);
$this->assertSame(8, array_sum(array_map(fn ($item) => $item->net_amount, array_filter($order->items, fn ($item) => $item->subscription_id === '01JSUB'))));
$this->assertSame('SUMMER20', $order->items[1]->discount->label);
$this->assertSame('RE-2026-0001', $order->invoice->number);
}
public function test_the_invoice_pdf_is_downloaded_with_the_app_token(): void
{
$this->http->append(new Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7 invoice'));
$this->respond(200, ['data' => ['id' => 'o1', 'user_id' => '01JUSER']]);
$this->http->append(new Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7 invoice'));
$this->assertSame('%PDF-1.7 invoice', app(AnikeenId::class)->appOrders()->invoicePdf('o1'));
$this->assertSame('%PDF-1.7 invoice', $this->owner()->appOrders()->invoicePdf('o1'));
$calls = array_map(fn ($entry) => $entry['request']->getMethod() . ' ' . $entry['request']->getUri()->getPath() . ' ' . $entry['request']->getHeaderLine('Authorization'), $this->sent);
$this->assertSame([
'GET /api/v1/client/orders/o1/invoice Bearer app-token',
'GET /api/v1/client/orders/o1 Bearer app-token',
'GET /api/v1/client/orders/o1/invoice Bearer app-token',
], $calls);
}
public function test_an_invoice_that_is_not_ready_is_a_conflict(): void
{
$this->respond(409, ['message' => 'The invoice document is still being created.', 'error' => 'invoice_not_ready']);
$this->respond(200, ['data' => ['id' => 'o1', 'user_id' => 'someone-else']]);
try {
app(AnikeenId::class)->appOrders()->invoicePdf('o1');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isConflict());
$this->assertSame('invoice_not_ready', $e->error);
}
try {
$this->owner()->appOrders()->invoicePdf('o1');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isNotFound());
$this->assertSame('order_not_found', $e->error);
}
}
} }
+96
View File
@@ -4,7 +4,10 @@ namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId; use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable; use Anikeen\Id\Billable;
use Anikeen\Id\Enums\CancellationReason;
use Anikeen\Id\Enums\TrialPaymentMethod;
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppSubscription;
use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Request;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -174,6 +177,99 @@ class AppSubscriptionsTest extends TestCase
} }
} }
public function test_a_subscription_starts_with_a_free_trial(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true, 'autorenew' => true, 'status' => 'trial', 'starts_at' => '2026-11-04T10:00:00+00:00', 'trial_ends_at' => date(DATE_ATOM, time() + 14 * 86400)]));
$subscription = $this->owner()->appSubscriptions()->create(['group' => 'team-1', 'name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'trial_days' => 14, 'trial_once' => true]);
$subscription->checkout();
$this->assertSame(['group' => 'team-1', 'name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'trial_days' => 14, 'trial_once' => true, 'user_id' => '01JUSER'], $this->sentJson(0));
$this->assertTrue($subscription->isTrial());
$this->assertTrue($subscription->isActive());
}
public function test_a_trial_can_be_confirmed_without_a_payment_method_and_end_cancelled(): void
{
$this->respond(201, $this->subscription(['trial_days' => 14, 'trial_payment_method' => 'optional']));
$this->respond(200, $this->subscription(['active' => false, 'cancelled' => true, 'status' => 'cancelled', 'starts_at' => '2026-11-04T10:00:00+00:00', 'trial_ends_at' => '2026-11-18T10:00:00+00:00', 'cancels_at' => '2026-11-18T10:00:00+00:00', 'trial_payment_method' => 'optional', 'cancellation_reason' => 'trial_ended_without_payment_method']));
$subscription = $this->owner()->appSubscriptions()->create(['name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'trial_days' => 14, 'trial_payment_method' => TrialPaymentMethod::OPTIONAL]);
$this->assertSame('optional', $this->sentJson(0)['trial_payment_method']);
$this->assertSame(TrialPaymentMethod::OPTIONAL, $subscription->trial_payment_method);
$ended = $this->owner()->appSubscriptions()->find('01JSUB');
$this->assertTrue($ended->isCancelled());
$this->assertSame(CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD, $ended->cancellation_reason);
}
public function test_a_used_trial_is_refused_on_checkout(): void
{
$this->respond(201, $this->subscription());
$this->respond(409, ['message' => 'The user already had a free trial.', 'error' => 'trial_already_used']);
$subscription = $this->owner()->appSubscriptions()->create(['name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'trial_ends_at' => '2026-12-01T00:00:00+01:00', 'trial_once' => true]);
try {
$subscription->checkout();
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isConflict());
$this->assertSame('trial_already_used', $e->error);
}
}
public function test_a_subscription_is_created_with_a_discount(): void
{
$discount = ['label' => 'SUMMER20', 'percent_off' => 20, 'duration' => 'repeating', 'duration_in_periods' => 3];
$this->respond(201, $this->subscription(['discount' => [...$discount, 'amount_off' => null, 'periods_remaining' => 3]]));
$subscription = $this->owner()->appSubscriptions()->create(['name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'discount' => $discount]);
$this->assertSame($discount, $this->sentJson(0)['discount']);
$this->assertSame('SUMMER20', $subscription->discount->label);
$this->assertSame(3, $subscription->discount->periods_remaining);
}
public function test_a_running_subscription_is_changed_now_or_at_the_period_end(): void
{
$change = ['applies_at' => '2026-12-01T00:00:00+00:00', 'name' => 'Pro', 'description' => null, 'unit' => 'month', 'price' => 14.99, 'discount' => null, 'created_at' => '2026-11-04T10:00:00+00:00'];
$this->respond(200, $this->subscription(['active' => true, 'price' => 14.99]));
$this->respond(200, $this->subscription(['active' => true, 'price' => 14.99, 'scheduled_change' => $change]));
$this->respond(200, $this->subscription(['active' => true, 'price' => 14.99, 'scheduled_change' => null]));
$subscription = new AppSubscription((object) $this->subscription(['active' => true])['data']);
$this->assertSame(14.99, $subscription->change(['apply' => AppSubscription::APPLY_NOW, 'price' => '14.99', 'discount' => null])->price);
$this->assertSame('2026-12-01T00:00:00+00:00', $subscription->change(['apply' => AppSubscription::APPLY_PERIOD_END, 'unit' => 'month', 'name' => 'Pro'])->scheduled_change->applies_at);
$this->assertNull($subscription->cancelScheduledChange()->scheduled_change);
$this->assertSame([
'PUT /api/v1/client/subscriptions/01JSUB/change Bearer app-token',
'PUT /api/v1/client/subscriptions/01JSUB/change Bearer app-token',
'DELETE /api/v1/client/subscriptions/01JSUB/change Bearer app-token',
], array_map(fn ($entry) => $entry['request']->getMethod() . ' ' . $entry['request']->getUri()->getPath() . ' ' . $entry['request']->getHeaderLine('Authorization'), $this->sent));
$this->assertSame(['apply' => 'now', 'price' => '14.99', 'discount' => null], $this->sentJson(0));
$this->assertSame(['apply' => 'period_end', 'unit' => 'month', 'name' => 'Pro'], $this->sentJson(1));
}
public function test_a_refused_change_carries_its_error_code(): void
{
$this->respond(409, ['message' => 'The credit would exceed the open bill.', 'error' => 'credit_exceeds_bill']);
$subscription = new AppSubscription((object) $this->subscription(['active' => true])['data']);
try {
$subscription->change(['apply' => AppSubscription::APPLY_NOW, 'price' => '1.99']);
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isConflict());
$this->assertSame('credit_exceeds_bill', $e->error);
}
}
public function test_a_missing_user_id_is_reported(): void public function test_a_missing_user_id_is_reported(): void
{ {
$this->expectException(InvalidArgumentException::class); $this->expectException(InvalidArgumentException::class);
+42
View File
@@ -2,7 +2,9 @@
namespace Anikeen\Id\Tests; namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable; use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\ConfirmationRequiredException; use Anikeen\Id\Exceptions\ConfirmationRequiredException;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -60,4 +62,44 @@ class CheckoutConfirmationTest extends TestCase
$this->assertSame('https://id.anikeen.com/checkout/subscriptions/01JSUB', $e->confirmationUrl); $this->assertSame('https://id.anikeen.com/checkout/subscriptions/01JSUB', $e->confirmationUrl);
} }
} }
public function test_a_trusted_app_lets_the_user_confirm_its_order(): void
{
$this->respond(201, ['data' => ['id' => '01JORD', 'status' => 'draft', 'group' => 'team-1', 'metadata' => ['topup' => '7'], 'expires_at' => '2026-11-05T10:00:00+00:00', 'confirmation_url' => 'https://id.anikeen.com/checkout/orders/01JORD']]);
$result = app(AnikeenId::class)->createOrderForUser('01JUSER', [
'group' => 'team-1',
'confirm' => true,
'return_url' => 'https://console.test/billing/topup',
'metadata' => ['topup' => '7'],
'items' => [['type' => 'digital', 'name' => 'Credit', 'unit' => 'onetime', 'units' => 1, 'gross_price' => '10.00']],
]);
$this->assertSame('POST /api/v1/admin/orders Bearer app-token', $this->sent[0]['request']->getMethod() . ' ' . $this->sent[0]['request']->getUri()->getPath() . ' ' . $this->sent[0]['request']->getHeaderLine('Authorization'));
$this->assertTrue($this->sentJson(0)['confirm']);
$this->assertSame('https://console.test/billing/topup', $this->sentJson(0)['return_url']);
$this->assertSame(['topup' => '7'], $this->sentJson(0)['metadata']);
$this->assertSame('01JUSER', $this->sentJson(0)['user_id']);
$this->assertSame('https://id.anikeen.com/checkout/orders/01JORD', $result->data->confirmation_url);
$this->assertSame('7', $result->data->metadata->topup);
}
public function test_an_expired_confirmation_is_a_conflict(): void
{
$this->respond(200, ['data' => []]);
$this->respond(201, ['data' => ['id' => '01JORD', 'status' => 'draft', 'confirmation_url' => 'https://id.anikeen.com/checkout/orders/01JORD']]);
$this->respond(409, ['message' => 'The order was not confirmed in time.', 'error' => 'confirmation_expired']);
$order = $this->owner()->orders()->create(['confirm' => true, 'return_url' => 'https://shop.test/done', 'metadata' => ['cart' => 1], 'items' => []]);
$this->assertSame(['confirm' => true, 'return_url' => 'https://shop.test/done', 'metadata' => ['cart' => 1], 'items' => []], $this->sentJson(1));
try {
$order->checkout();
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isConflict());
$this->assertSame('confirmation_expired', $e->error);
}
}
} }
+29
View File
@@ -139,6 +139,32 @@ class CheckoutSessionsTest extends TestCase
$this->assertNull($user->checkoutSessions()->find('unknown')); $this->assertNull($user->checkoutSessions()->find('unknown'));
} }
public function test_session_subscriptions_carry_a_free_trial(): void
{
$this->respond(201, $this->sessionData());
$this->owner()->appCheckoutSessions()->create(['subscriptions' => [
['group' => 'team-1', 'name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'trial_days' => 14, 'trial_once' => true],
['group' => 'team-1', 'name' => 'Backup', 'unit' => 'month', 'price' => '1.99', 'trial_ends_at' => '2026-12-01T00:00:00+00:00'],
]]);
$this->assertSame(14, $this->sentJson(0)['subscriptions'][0]['trial_days']);
$this->assertTrue($this->sentJson(0)['subscriptions'][0]['trial_once']);
$this->assertSame('2026-12-01T00:00:00+00:00', $this->sentJson(0)['subscriptions'][1]['trial_ends_at']);
}
public function test_session_subscriptions_carry_a_discount(): void
{
$this->respond(201, $this->sessionData());
$this->owner()->checkoutSessions()->create(['subscriptions' => [
['name' => 'Pro', 'unit' => 'month', 'price' => 4.99, 'discount' => ['label' => 'WELCOME', 'amount_off' => '1.00', 'duration' => 'once']],
]]);
$this->assertSame('POST /api/v1/checkout-sessions Bearer user-token', $this->request(0));
$this->assertSame(['label' => 'WELCOME', 'amount_off' => '1.00', 'duration' => 'once'], $this->sentJson(0)['subscriptions'][0]['discount']);
}
public function test_nothing_is_requested_until_a_method_is_called(): void public function test_nothing_is_requested_until_a_method_is_called(): void
{ {
$this->owner()->checkoutSessions(); $this->owner()->checkoutSessions();
@@ -159,5 +185,8 @@ class CheckoutSessionsTest extends TestCase
$this->assertSame(['01JS1', '01JS2'], $event['data']['object']['subscription_ids']); $this->assertSame(['01JS1', '01JS2'], $event['data']['object']['subscription_ids']);
$this->assertContains('checkout.expired', EventType::all()); $this->assertContains('checkout.expired', EventType::all());
$this->assertContains('subscription.activated', EventType::all()); $this->assertContains('subscription.activated', EventType::all());
$this->assertContains('subscription.trial_will_end', EventType::all());
$this->assertContains('subscription.discount_ended', EventType::all());
$this->assertContains('subscription.updated', EventType::all());
} }
} }
+30
View File
@@ -4,6 +4,7 @@ namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId; use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable; use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** /**
@@ -199,6 +200,35 @@ class EndpointsTest extends TestCase
$this->assertSame('POST /api/v1/admin/transactions Bearer app-token', $this->sentCall(1)); $this->assertSame('POST /api/v1/admin/transactions Bearer app-token', $this->sentCall(1));
} }
public function test_billing_portal_links_for_users_use_the_app_token(): void
{
$link = ['object' => 'billing_portal_link', 'url' => 'https://id.test/billing/portal/01JUSER?signature=x', 'user_id' => '01JUSER', 'flow' => 'invoices', 'group' => 'team-1', 'return_url' => 'https://console.test/billing', 'expires_at' => '2026-11-04T10:15:00+00:00'];
$this->respond(201, ['data' => $link]);
$this->respond(201, ['data' => $link]);
$created = app(AnikeenId::class)->createBillingPortalLinkForUser('01JUSER', ['return_url' => 'https://console.test/billing', 'group' => 'team-1', 'flow' => 'invoices']);
$url = $this->owner()->appBillingPortalUrl(['flow' => 'payment_method_update']);
$this->assertSame('billing_portal_link', $created->object);
$this->assertSame($link['url'], $url);
$this->assertSame('POST /api/v1/client/users/01JUSER/billing-portal Bearer app-token', $this->sentCall(0));
$this->assertSame(['return_url' => 'https://console.test/billing', 'group' => 'team-1', 'flow' => 'invoices'], $this->sentJson(0));
$this->assertSame('POST /api/v1/client/users/01JUSER/billing-portal Bearer app-token', $this->sentCall(1));
$this->assertSame(['flow' => 'payment_method_update'], $this->sentJson(1));
}
public function test_a_refused_billing_portal_link_is_an_api_exception(): void
{
$this->respond(422, ['message' => 'The return URL has to point to one of the app\'s redirect URIs.', 'errors' => ['return_url' => ['…']]]);
try {
$this->owner()->appBillingPortalUrl(['return_url' => 'https://elsewhere.test']);
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertSame(422, $e->status);
}
}
public function test_order_preview_maps_the_country_into_the_billing_address(): void public function test_order_preview_maps_the_country_into_the_billing_address(): void
{ {
$this->respond(200, ['data' => ['gross_total' => 11.9]]); $this->respond(200, ['data' => ['gross_total' => 11.9]]);
+1
View File
@@ -47,6 +47,7 @@ class SubscriptionStatusTest extends TestCase
$this->assertSame($status, $resource->status()); $this->assertSame($status, $resource->status());
$this->assertSame($running, $resource->isActive()); $this->assertSame($running, $resource->isActive());
$this->assertSame($status === SubscriptionStatus::DRAFT, $resource->isDraft()); $this->assertSame($status === SubscriptionStatus::DRAFT, $resource->isDraft());
$this->assertSame($status === SubscriptionStatus::TRIAL, $resource->isTrial());
$this->assertSame($status === SubscriptionStatus::PAUSED, $resource->isPaused()); $this->assertSame($status === SubscriptionStatus::PAUSED, $resource->isPaused());
$this->assertSame($status === SubscriptionStatus::CANCELLING, $resource->isCancelling()); $this->assertSame($status === SubscriptionStatus::CANCELLING, $resource->isCancelling());
$this->assertSame($status === SubscriptionStatus::EXPIRED, $resource->isExpired()); $this->assertSame($status === SubscriptionStatus::EXPIRED, $resource->isExpired());
+28
View File
@@ -3,6 +3,7 @@
namespace Anikeen\Id\Tests; namespace Anikeen\Id\Tests;
use Anikeen\Id\Exceptions\SignatureVerificationException; use Anikeen\Id\Exceptions\SignatureVerificationException;
use Anikeen\Id\Webhooks\EventType;
use Anikeen\Id\Webhooks\Webhook; use Anikeen\Id\Webhooks\Webhook;
class WebhookTest extends TestCase class WebhookTest extends TestCase
@@ -53,4 +54,31 @@ class WebhookTest extends TestCase
Webhook::constructEvent($this->payload, null, $this->secret); Webhook::constructEvent($this->payload, null, $this->secret);
} }
public function test_a_renewal_carries_the_booked_period(): void
{
$this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_RENEWED, 'created' => time(), 'data' => [
'object' => ['id' => '01JSUB', 'status' => 'active'],
'period' => ['object' => 'subscription_period', 'subscription_id' => '01JSUB', 'period_start' => '2026-11-01T00:00:00+00:00', 'period_end' => '2026-12-01T00:00:00+00:00', 'periods' => 1, 'first_period' => true, 'unit' => 'month', 'order_id' => '01JORD', 'order_item_id' => '01JITEM', 'items' => [], 'currency_iso' => 'EUR', 'net_amount' => 8],
]]);
$event = Webhook::constructEvent($this->payload, $this->header(), $this->secret);
$this->assertSame('subscription.renewed', $event['type']);
$this->assertSame('01JORD', $event['data']['period']['order_id']);
$this->assertTrue($event['data']['period']['first_period']);
$this->assertContains(EventType::SUBSCRIPTION_RENEWED, EventType::all());
}
public function test_a_change_carries_the_previous_attributes(): void
{
$this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [
'object' => ['id' => '01JSUB', 'price' => 14.99],
'previous_attributes' => ['price' => 9.99],
]]);
$event = Webhook::constructEvent($this->payload, $this->header(), $this->secret);
$this->assertSame(9.99, $event['data']['previous_attributes']['price']);
}
} }