Compare commits

..
Author SHA1 Message Date
envoyr 2b47be157b Release 3.2.0 2026-11-06 11:38:47 +00:00
envoyr 2796befc7d Merge pull request #19 from anikeen-com/feature/bill-elapsed-periods
Bill elapsed periods and look up users by email
2026-11-06 11:26:19 +00:00
envoyr 64ccb84987 Document elapsed periods and user accounts 2026-11-06 10:26:51 +00:00
envoyr 7813e8fbaa Look up users by email and document invitations 2026-11-06 10:26:51 +00:00
envoyr 27e868720f Bill elapsed periods on app subscription checkout 2026-11-06 10:26:51 +00:00
26 changed files with 379 additions and 1085 deletions

No files matched your search

+13 -15
View File
@@ -1,22 +1,20 @@
# Changelog # Changelog
## Unreleased ## 3.2.0 (2026-11-06)
App billing features; they need an Anikeen ID release that supports them. Elapsed-period billing and user lookup; they need an Anikeen ID release that supports them.
- Free trials: `trial_days` / `trial_ends_at` (optional `trial_once`, `trial_payment_method` with - `AppSubscription::checkout()` takes `billElapsedPeriods` and `maxNetAmount`: a subscription
`TrialPaymentMethod::REQUIRED` / `OPTIONAL`) when creating subscriptions and checkout sessions; started more than one period ago is billed for its elapsed periods too, up to the given net
`Subscription::isTrial()`. amount (409 `elapsed_amount_exceeded` with `net_amount`, `elapsed_periods_not_allowed`). Calls
- Discounts per billed period: `discount` with `percent_off` or `amount_off`, `duration` and `label`. without the new arguments send the same request as before.
- Plan changes of running subscriptions: `AppSubscription::change()` (`apply: now` or - `ApiException::body()` returns the JSON body of Anikeen ID's error response, e.g. its
`period_end`) and `AppSubscription::cancelScheduledChange()`. `net_amount`.
- App orders: order lines with subscription, period, net amount and discount; - Add `AnikeenId::findUserByEmail()` for trusted apps: looks up an account by email address
`AppOrders::invoicePdf()`. Confirmable orders of trusted apps (`confirm: true`). (regardless of case) without creating one, null when there is none.
- Billing portal links that work without a login: `createBillingPortalLinkForUser()` and - `createUser()` documents `name`, `password_confirmation` and `send_invitation` (a newly created
`appBillingPortalUrl()`. account gets an invitation mail to set a password) and the response fields `created` and
- Webhook events `subscription.trial_will_end`, `subscription.discount_ended`, `invitation_sent`.
`subscription.updated` and `subscription.renewed` (`EventType::SUBSCRIPTION_*`);
`data.reason` with `CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD`.
## 3.1.0 (2026-11-05) ## 3.1.0 (2026-11-05)
+66 -244
View File
@@ -382,102 +382,8 @@ 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()`, `isTrial()`, `isPaused()`, `isCancelling()`, There are shortcuts for each: `isDraft()`, `isPaused()`, `isCancelling()`, `isExpired()`,
`isExpired()`, `isCancelled()`. App subscriptions (`appSubscriptions()`) have the same methods. `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
@@ -530,25 +436,6 @@ 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
@@ -627,6 +514,11 @@ $subscription = $subscriptions->create([
$subscription->checkout(); // bill the first period $subscription->checkout(); // bill the first period
$subscription->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then $subscription->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then
$subscription->checkout( // running for longer: also bill the periods that have ended
startsAt: $domain->registered_at,
billElapsedPeriods: true,
maxNetAmount: '15.57', // net ceiling, e.g. the amount the customer agreed to
);
$subscription->pause(); // expires when the paid period ends $subscription->pause(); // expires when the paid period ends
$subscription->resume(); // expired subscriptions start a new period $subscription->resume(); // expired subscriptions start a new period
$subscription->revoke(refund: false); // cancel $subscription->revoke(refund: false); // cancel
@@ -639,11 +531,44 @@ The user's Anikeen ID is read from the billable's `anikeen_id` attribute
(`AnikeenId::useUserIdField('…')` to change it). The app must be trusted by Anikeen ID; it sees the (`AnikeenId::useUserIdField('…')` to change it). The app must be trusted by Anikeen ID; it sees the
subscriptions it created (plus those of clients delegated to it). subscriptions it created (plus those of clients delegated to it).
#### Starting from an earlier date
`checkout(startsAt: …)` books a service that is already running, e.g. a domain registered before
the customer came to your app. If the first period from that date is still running, it is billed
now and renews when it ends. An older start is refused with `period_already_ended`, unless you
pass `billElapsedPeriods: true` with `maxNetAmount` (net, up to two decimals; both together, or
`checkout()` throws an `InvalidArgumentException` without sending anything):
- every period that has ended plus the running one is billed now, as one line with the full
service period. It lands on the user's collective order of the current month and is invoiced and
charged with the next monthly bill; nothing is back-dated.
- the start may be at most about three years ago (422 otherwise).
- if the periods come to more than `maxNetAmount`, nothing is booked: 409
`elapsed_amount_exceeded`, with the real amount in `$e->body()->net_amount` (e.g. `"20.76"`), so
you can show it and retry.
- 409 `elapsed_periods_not_allowed` when the subscription can't be billed this way (e.g. it ran
before, replaces another one, or is billed by the hour or day with a monthly cap).
```php
try {
$subscription->checkout(startsAt: $startsAt, billElapsedPeriods: true, maxNetAmount: $confirmed);
} catch (ApiException $e) {
if ($e->error === 'elapsed_amount_exceeded') {
$actual = $e->body()->net_amount; // ask again with the real amount
} else {
throw $e;
}
}
```
#### Error codes
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`, `in_checkout_session`, `trial_already_used`, `proration_not_possible`, `idempotency_key_conflict`, `period_already_ended`, `elapsed_periods_not_allowed`,
`credit_exceeds_bill`) and `isNotFound()`, `isConflict()`, `isTemporary()`: `elapsed_amount_exceeded`), the error response via `body()` and `isNotFound()`, `isConflict()`,
`isTemporary()`:
```php ```php
try { try {
@@ -659,70 +584,6 @@ 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`):
@@ -737,67 +598,6 @@ 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,
@@ -855,6 +655,29 @@ While a session is open, its subscriptions can't be checked out or repriced by t
`subscription.activated` / `subscription.cancelled` are still sent per subscription, before the `subscription.activated` / `subscription.cancelled` are still sent per subscription, before the
`checkout.*` event of the session. `checkout.*` event of the session.
### User accounts (trusted apps, no user token)
Trusted apps can look up and create Anikeen ID accounts with the app's own token. Email addresses
are matched regardless of case.
```php
$anikeenId = app(AnikeenId::class);
$account = $anikeenId->findUserByEmail('erika@example.com'); // null if no account uses it
$account?->data->id; // also name, email, email_verified, has_billing_address
$result = $anikeenId->createUser([ // finds the account of the email address or creates it
'name' => 'Erika Mustermann',
'email' => 'erika@example.com',
'send_invitation' => true, // a new account gets a mail with a link to set a password
]);
$result->data->created; // false: the account already existed (and is unchanged)
$result->data->invitation_sent; // only for an account created by this call without a password
```
`findUserByEmail()` changes nothing. It throws `ApiException` when the request fails for any reason
other than an unknown address.
## API authentication ## API authentication
@@ -1051,7 +874,6 @@ 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
``` ```
@@ -1073,6 +895,7 @@ public function sshKeysByUserId(string $userId): SshKeys
```php ```php
public function getAuthedUser(): Result public function getAuthedUser(): Result
public function createUser(array $attributes): Result public function createUser(array $attributes): Result
public function findUserByEmail(string $email): ?Result
public function findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result public function findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result
public function refreshToken(string $storedRefreshToken, string $scope = ''): Result public function refreshToken(string $storedRefreshToken, string $scope = ''): Result
public function isEmailExisting(string $email): Result public function isEmailExisting(string $email): Result
@@ -1157,7 +980,6 @@ 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
``` ```
+65 -242
View File
@@ -382,102 +382,8 @@ 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()`, `isTrial()`, `isPaused()`, `isCancelling()`, There are shortcuts for each: `isDraft()`, `isPaused()`, `isCancelling()`, `isExpired()`,
`isExpired()`, `isCancelled()`. App subscriptions (`appSubscriptions()`) have the same methods. `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
@@ -530,25 +436,6 @@ 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
@@ -627,6 +514,11 @@ $subscription = $subscriptions->create([
$subscription->checkout(); // bill the first period $subscription->checkout(); // bill the first period
$subscription->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then $subscription->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then
$subscription->checkout( // running for longer: also bill the periods that have ended
startsAt: $domain->registered_at,
billElapsedPeriods: true,
maxNetAmount: '15.57', // net ceiling, e.g. the amount the customer agreed to
);
$subscription->pause(); // expires when the paid period ends $subscription->pause(); // expires when the paid period ends
$subscription->resume(); // expired subscriptions start a new period $subscription->resume(); // expired subscriptions start a new period
$subscription->revoke(refund: false); // cancel $subscription->revoke(refund: false); // cancel
@@ -639,11 +531,44 @@ The user's Anikeen ID is read from the billable's `anikeen_id` attribute
(`AnikeenId::useUserIdField('…')` to change it). The app must be trusted by Anikeen ID; it sees the (`AnikeenId::useUserIdField('…')` to change it). The app must be trusted by Anikeen ID; it sees the
subscriptions it created (plus those of clients delegated to it). subscriptions it created (plus those of clients delegated to it).
#### Starting from an earlier date
`checkout(startsAt: …)` books a service that is already running, e.g. a domain registered before
the customer came to your app. If the first period from that date is still running, it is billed
now and renews when it ends. An older start is refused with `period_already_ended`, unless you
pass `billElapsedPeriods: true` with `maxNetAmount` (net, up to two decimals; both together, or
`checkout()` throws an `InvalidArgumentException` without sending anything):
- every period that has ended plus the running one is billed now, as one line with the full
service period. It lands on the user's collective order of the current month and is invoiced and
charged with the next monthly bill; nothing is back-dated.
- the start may be at most about three years ago (422 otherwise).
- if the periods come to more than `maxNetAmount`, nothing is booked: 409
`elapsed_amount_exceeded`, with the real amount in `$e->body()->net_amount` (e.g. `"20.76"`), so
you can show it and retry.
- 409 `elapsed_periods_not_allowed` when the subscription can't be billed this way (e.g. it ran
before, replaces another one, or is billed by the hour or day with a monthly cap).
```php
try {
$subscription->checkout(startsAt: $startsAt, billElapsedPeriods: true, maxNetAmount: $confirmed);
} catch (ApiException $e) {
if ($e->error === 'elapsed_amount_exceeded') {
$actual = $e->body()->net_amount; // ask again with the real amount
} else {
throw $e;
}
}
```
#### Error codes
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`, `in_checkout_session`, `trial_already_used`, `proration_not_possible`, `idempotency_key_conflict`, `period_already_ended`, `elapsed_periods_not_allowed`,
`credit_exceeds_bill`) and `isNotFound()`, `isConflict()`, `isTemporary()`: `elapsed_amount_exceeded`), the error response via `body()` and `isNotFound()`, `isConflict()`,
`isTemporary()`:
```php ```php
try { try {
@@ -659,70 +584,6 @@ 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`):
@@ -737,67 +598,6 @@ 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,
@@ -855,6 +655,29 @@ While a session is open, its subscriptions can't be checked out or repriced by t
`subscription.activated` / `subscription.cancelled` are still sent per subscription, before the `subscription.activated` / `subscription.cancelled` are still sent per subscription, before the
`checkout.*` event of the session. `checkout.*` event of the session.
### User accounts (trusted apps, no user token)
Trusted apps can look up and create Anikeen ID accounts with the app's own token. Email addresses
are matched regardless of case.
```php
$anikeenId = app(AnikeenId::class);
$account = $anikeenId->findUserByEmail('erika@example.com'); // null if no account uses it
$account?->data->id; // also name, email, email_verified, has_billing_address
$result = $anikeenId->createUser([ // finds the account of the email address or creates it
'name' => 'Erika Mustermann',
'email' => 'erika@example.com',
'send_invitation' => true, // a new account gets a mail with a link to set a password
]);
$result->data->created; // false: the account already existed (and is unchanged)
$result->data->invitation_sent; // only for an account created by this call without a password
```
`findUserByEmail()` changes nothing. It throws `ApiException` when the request fails for any reason
other than an unknown address.
## API authentication ## API authentication
@@ -48,14 +48,6 @@ 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,8 +2,6 @@
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;
@@ -67,20 +65,4 @@ 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;
}
} }
+1 -47
View File
@@ -2,11 +2,9 @@
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;
/** /**
@@ -31,48 +29,11 @@ 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,
@@ -82,18 +43,11 @@ 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 confirm: needs items, can't be combined with checkout or a transaction. * } $attributes
* 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
+45 -7
View File
@@ -5,7 +5,9 @@ namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Get; use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\ApiOperations\Post; use Anikeen\Id\ApiOperations\Post;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use Anikeen\Id\Support\AppRequest;
use Throwable; use Throwable;
trait ManagesUsers trait ManagesUsers
@@ -24,20 +26,32 @@ trait ManagesUsers
} }
/** /**
* Creates a new user on behalf of the current user. * Finds the account of an email address or creates it (trusted apps, app token). The email
* address is matched regardless of case; a new account stores it in lower case, unverified.
* An existing account is returned unchanged (only a missing name is filled in).
* *
* @param array{ * @param array{
* first_name: null|string, * name?: null|string,
* last_name: null|string, * first_name?: null|string,
* username: null|string, * last_name?: null|string,
* username?: null|string,
* email: string, * email: string,
* password: null|string * password?: null|string,
* password_confirmation?: null|string,
* send_invitation?: bool
* } $attributes The user data * } $attributes The user data
* - first_name: The first name (optional) * - name: The display name (optional)
* - last_name: The last name (optional) * - first_name: The first name (optional, joined with last_name into name)
* - last_name: The last name (optional, joined with first_name into name)
* - username: The username (optional) * - username: The username (optional)
* - email: The email (required) * - email: The email (required)
* - password: The password (optional, can be reset by the user if not provided) * - password: The password (optional, can be reset by the user if not provided)
* - password_confirmation: Defaults to password
* - send_invitation: Send the user an invitation mail with a link to set a password
* (optional, only when this call created the account without a
* password, never for an existing account)
* @return Result `data` is the user plus `created` (this call created the account) and
* `invitation_sent` (the invitation mail was sent); 201 when created, 200 when it existed
* @throws Throwable * @throws Throwable
*/ */
public function createUser(array $attributes): Result public function createUser(array $attributes): Result
@@ -57,6 +71,30 @@ trait ManagesUsers
return $this->asApp()->post('v1/admin/users', $attributes); return $this->asApp()->post('v1/admin/users', $attributes);
} }
/**
* Looks up the account of an email address without creating or changing anything (trusted
* apps, app token). The email address is matched regardless of case.
*
* `data` is `object{id: string, name: ?string, email: string, email_verified: bool,
* has_billing_address: bool}`.
*
* @return Result|null null when no account uses the email address
* @throws ApiException when the request fails for another reason
*/
public function findUserByEmail(string $email): ?Result
{
try {
return AppRequest::send('POST', 'v1/admin/users/lookup', ['email' => $email]);
} catch (ApiException $e) {
// only Anikeen ID's own answer means "no account", not e.g. an unknown route
if ($e->isNotFound() && $e->error === 'user_not_found') {
return null;
}
throw $e;
}
}
/** /**
* Find the user of a social login or create it (trusted apps, app token). * Find the user of a social login or create it (trusted apps, app token).
* *
-13
View File
@@ -1,13 +0,0 @@
<?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
@@ -1,20 +0,0 @@
<?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';
}
+19
View File
@@ -3,6 +3,7 @@
namespace Anikeen\Id\Exceptions; namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use Psr\Http\Message\ResponseInterface;
use Throwable; use Throwable;
/** /**
@@ -44,6 +45,24 @@ class ApiException extends ResourceException
return new static(sprintf('Anikeen ID is not reachable: %s', $previous->getMessage()), 0, null, $previous); return new static(sprintf('Anikeen ID is not reachable: %s', $previous->getMessage()), 0, null, $previous);
} }
/**
* The JSON body of Anikeen ID's error response, null without one. Some errors carry details,
* e.g. `net_amount` of "elapsed_amount_exceeded".
*/
public function body(): ?object
{
$previous = $this->getPrevious();
$response = $previous !== null && method_exists($previous, 'getResponse') ? $previous->getResponse() : null;
if (!$response instanceof ResponseInterface) {
return null;
}
$body = json_decode((string) $response->getBody());
return is_object($body) ? $body : null;
}
public function isNotFound(): bool public function isNotFound(): bool
{ {
return $this->status === 404; return $this->status === 404;
+1 -11
View File
@@ -36,12 +36,7 @@ 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,
@@ -50,11 +45,6 @@ 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"
*/ */
+3 -32
View File
@@ -13,22 +13,9 @@ 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, oauth_client_id (the app that bills it: collective * Each order is an object with id, user_id, group, status (processing|pending|completed|cancelled),
* orders are kept per app, even for the same group; null on legacy orders that billed several * is_collective, net_total, vat_total, gross_total, currency_iso, created_at, paid_at,
* apps), group, metadata, status (processing|pending|completed|cancelled), is_collective, * payment_attempts, next_payment_attempt_at and last_payment_error ({code, message} or null).
* 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
{ {
@@ -94,22 +81,6 @@ 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();
+35 -59
View File
@@ -6,6 +6,7 @@ use Anikeen\Id\Concerns\HasSubscriptionStatus;
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest; use Anikeen\Id\Support\AppRequest;
use DateTimeInterface; use DateTimeInterface;
use InvalidArgumentException;
use JsonSerializable; use JsonSerializable;
/** /**
@@ -28,9 +29,6 @@ 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
@@ -39,18 +37,11 @@ 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)
{ {
} }
@@ -69,7 +60,6 @@ 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
*/ */
@@ -78,68 +68,54 @@ 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.
* *
* $startsAt (in the past) books a service that is already running, e.g. a domain registered * $startsAt (in the past) books a service that is already running, e.g. a domain registered
* earlier: the first period runs from $startsAt, is billed now and renews when it ends. * earlier: the first period runs from $startsAt, is billed now and renews when it ends.
* *
* @throws ApiException 409 "already_active", "billing_address_missing", "period_already_ended", … * A $startsAt older than one period is refused ("period_already_ended") unless
* $billElapsedPeriods is set: then the periods that have already ended are billed too, together
* with the running one, as long as they come to at most $maxNetAmount (net, up to two
* decimals) and $startsAt lies within the maximum age Anikeen ID accepts (about three years).
* The lines land on the current month's collective order, which is charged on the 1st of the
* next month. $billElapsedPeriods needs both $startsAt and $maxNetAmount.
*
* @throws ApiException 409 "already_active", "billing_address_missing", "period_already_ended",
* "elapsed_periods_not_allowed" (the elapsed periods can't be billed for this subscription),
* "elapsed_amount_exceeded" (they come to more than $maxNetAmount; the response body has
* the booked `net_amount`), …; 422 invalid input (e.g. a start older than the maximum age)
* @throws InvalidArgumentException $billElapsedPeriods without $startsAt or $maxNetAmount
*/ */
public function checkout(DateTimeInterface|string|null $startsAt = null): self public function checkout(
{ DateTimeInterface|string|null $startsAt = null,
bool $billElapsedPeriods = false,
float|string|null $maxNetAmount = null,
): self {
if ($startsAt instanceof DateTimeInterface) { if ($startsAt instanceof DateTimeInterface) {
$startsAt = $startsAt->format(DateTimeInterface::ATOM); $startsAt = $startsAt->format(DateTimeInterface::ATOM);
} }
if (!$billElapsedPeriods) {
return $this->send('PUT', '/checkout', $startsAt === null ? null : ['starts_at' => $startsAt]); return $this->send('PUT', '/checkout', $startsAt === null ? null : ['starts_at' => $startsAt]);
} }
if ($startsAt === null || $startsAt === '') {
throw new InvalidArgumentException('Billing elapsed periods needs a start date ($startsAt).');
}
if ($maxNetAmount === null || $maxNetAmount === '') {
throw new InvalidArgumentException('Billing elapsed periods needs a maximum net amount ($maxNetAmount).');
}
return $this->send('PUT', '/checkout', [
'starts_at' => $startsAt,
'bill_elapsed_periods' => true,
// floats from calculations may carry binary noise (e.g. 59.970000000000006)
'max_net_amount' => is_float($maxNetAmount) ? round($maxNetAmount, 2) : $maxNetAmount,
]);
}
/** /**
* Cancel the subscription. With $refund (only within 14 days of its start), its not yet * Cancel the subscription. With $refund (only within 14 days of its start), its not yet
* charged usage is credited. * charged usage is credited.
+1 -19
View File
@@ -49,11 +49,6 @@ 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.
@@ -61,21 +56,8 @@ 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 422 validation, 409 "idempotency_key_conflict", … * @throws ApiException
*/ */
public function create(array $attributes): AppSubscription public function create(array $attributes): AppSubscription
{ {
+2 -15
View File
@@ -8,14 +8,7 @@ use Throwable;
/** /**
* @property string $id * @property string $id
* @property null|string $confirmation_url where the user confirms it (third-party apps, or `confirm`) * @property null|string $confirmation_url where the user confirms it (third-party apps)
* @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
{ {
@@ -60,14 +53,10 @@ 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
@@ -80,8 +69,6 @@ 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,10 +48,6 @@ 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,
@@ -65,10 +61,6 @@ 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,9 +27,6 @@ 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
+1 -11
View File
@@ -24,12 +24,7 @@ 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
@@ -41,11 +36,6 @@ 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,34 +18,9 @@ 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,8 +4,6 @@ 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
@@ -70,65 +68,4 @@ 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);
}
}
} }
+81 -96
View File
@@ -4,10 +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\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;
@@ -90,6 +87,87 @@ class AppSubscriptionsTest extends TestCase
$this->assertSame(['starts_at' => '2026-05-19T11:33:58+00:00'], $this->sentJson(1)); $this->assertSame(['starts_at' => '2026-05-19T11:33:58+00:00'], $this->sentJson(1));
} }
public function test_a_checkout_without_arguments_sends_no_body(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$this->owner()->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1])->checkout();
$request = $this->sent[1]['request'];
$this->assertSame('PUT /api/v1/client/subscriptions/01JSUB/checkout', $request->getMethod() . ' ' . $request->getUri()->getPath());
$this->assertSame('', (string) $request->getBody());
$this->assertFalse($request->hasHeader('Content-Type'));
}
public function test_a_checkout_from_a_date_sends_only_the_start(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19'])
->checkout('2026-05-19T11:33:58+00:00');
$this->assertSame('{"starts_at":"2026-05-19T11:33:58+00:00"}', (string) $this->sent[1]['request']->getBody());
}
public function test_elapsed_periods_are_billed_up_to_the_ceiling(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19'])
->checkout(new \DateTimeImmutable('2024-05-19 11:33:58', new \DateTimeZone('UTC')), billElapsedPeriods: true, maxNetAmount: '15.57');
$request = $this->sent[1]['request'];
$this->assertSame('PUT /api/v1/client/subscriptions/01JSUB/checkout', $request->getMethod() . ' ' . $request->getUri()->getPath());
$this->assertSame(['starts_at' => '2024-05-19T11:33:58+00:00', 'bill_elapsed_periods' => true, 'max_net_amount' => '15.57'], $this->sentJson(1));
}
public function test_a_float_ceiling_is_sent_with_two_decimals(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => 19.99])
->checkout('2024-05-19', true, 19.99 * 3);
$this->assertSame(['starts_at' => '2024-05-19', 'bill_elapsed_periods' => true, 'max_net_amount' => 59.97], $this->sentJson(1));
}
public function test_billing_elapsed_periods_needs_a_start_and_a_ceiling(): void
{
$this->respond(201, $this->subscription());
$subscription = $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']);
foreach ([[null, '10.00'], ['2024-05-19', null], ['', '10.00'], ['2024-05-19', '']] as [$startsAt, $maxNetAmount]) {
try {
$subscription->checkout($startsAt, billElapsedPeriods: true, maxNetAmount: $maxNetAmount);
$this->fail('Expected an InvalidArgumentException.');
} catch (InvalidArgumentException) {
}
}
$this->assertCount(1, $this->sent);
}
public function test_an_exceeded_ceiling_carries_the_net_amount(): void
{
$this->respond(201, $this->subscription());
$this->respond(409, ['message' => 'The elapsed periods come to 20.76 net, more than 15.57.', 'error' => 'elapsed_amount_exceeded', 'net_amount' => '20.76']);
$subscription = $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']);
try {
$subscription->checkout('2024-05-19', billElapsedPeriods: true, maxNetAmount: '15.57');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isConflict());
$this->assertSame('elapsed_amount_exceeded', $e->error);
$this->assertSame('20.76', $e->body()->net_amount);
}
}
public function test_errors_carry_status_and_error_code(): void public function test_errors_carry_status_and_error_code(): void
{ {
$this->respond(409, ['message' => 'Product subscription has already been cancelled.', 'error' => 'already_cancelled']); $this->respond(409, ['message' => 'Product subscription has already been cancelled.', 'error' => 'already_cancelled']);
@@ -177,99 +255,6 @@ 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,9 +2,7 @@
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;
@@ -62,44 +60,4 @@ 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,32 +139,6 @@ 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();
@@ -185,8 +159,5 @@ 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());
} }
} }
+46 -29
View File
@@ -91,6 +91,52 @@ class EndpointsTest extends TestCase
$this->assertSame(['email' => 'erika@example.com', 'password' => 'secret-123', 'name' => 'Erika Mustermann', 'password_confirmation' => 'secret-123'], $this->sentJson(0)); $this->assertSame(['email' => 'erika@example.com', 'password' => 'secret-123', 'name' => 'Erika Mustermann', 'password_confirmation' => 'secret-123'], $this->sentJson(0));
} }
public function test_create_user_can_send_an_invitation(): void
{
$this->respond(201, ['data' => ['id' => '01JNEW', 'email' => 'erika@example.com', 'created' => true, 'invitation_sent' => true]]);
$result = app(AnikeenId::class)->createUser(['name' => 'Erika Mustermann', 'email' => 'erika@example.com', 'send_invitation' => true]);
$this->assertSame('POST /api/v1/admin/users Bearer app-token', $this->sentCall(0));
$this->assertSame(['name' => 'Erika Mustermann', 'email' => 'erika@example.com', 'send_invitation' => true], $this->sentJson(0));
$this->assertTrue($result->data->created);
$this->assertTrue($result->data->invitation_sent);
}
public function test_find_user_by_email_uses_the_lookup_endpoint(): void
{
$this->respond(200, ['data' => ['id' => '01JUSER', 'name' => 'Erika', 'email' => 'erika@example.com', 'email_verified' => true, 'has_billing_address' => false]]);
$result = app(AnikeenId::class)->findUserByEmail('Erika@Example.com');
$this->assertSame('POST /api/v1/admin/users/lookup Bearer app-token', $this->sentCall(0));
$this->assertSame(['email' => 'Erika@Example.com'], $this->sentJson(0));
$this->assertSame('01JUSER', $result->data->id);
$this->assertFalse($result->data->has_billing_address);
}
public function test_find_user_by_email_returns_null_without_an_account(): void
{
$this->respond(404, ['message' => 'User not found.', 'error' => 'user_not_found']);
$this->assertNull(app(AnikeenId::class)->findUserByEmail('nobody@example.com'));
}
public function test_find_user_by_email_throws_other_failures(): void
{
$this->respond(404, ['message' => 'Not found.']); // e.g. an Anikeen ID without the lookup
$this->respond(500, ['message' => 'Server Error']);
foreach ([404, 500] as $status) {
try {
app(AnikeenId::class)->findUserByEmail('erika@example.com');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertSame($status, $e->status);
}
}
}
public function test_email_check_and_socialite_users(): void public function test_email_check_and_socialite_users(): void
{ {
$this->respond(200, ['data' => ['exists' => true]]); $this->respond(200, ['data' => ['exists' => true]]);
@@ -200,35 +246,6 @@ 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,7 +47,6 @@ 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,7 +3,6 @@
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
@@ -54,31 +53,4 @@ 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']);
}
} }