Document trials without a payment method

trial_payment_method (TrialPaymentMethod::REQUIRED, the default, or
::OPTIONAL) on app subscriptions, subscriptions and checkout sessions:
an optional trial can be confirmed without a payment method and is
cancelled at its end if there is still none (cancellation_reason and
subscription.cancelled data.reason
CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD).
subscription.trial_will_end carries data.payment_method_missing.
This commit is contained in:
Maurice Preuß committed 2026-11-04 22:44:03 +01:00
1 parent a68e9ed257
commit a54b5fe598
11 files changed
+151 -3

No files matched your search

+41 -1
View File
@@ -383,10 +383,50 @@ $subscription = $user->subscriptions()->create([
``` ```
A few days before the trial ends, Anikeen ID sends `subscription.trial_will_end` 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. Cancelling during the trial (`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 bills nothing. Trials work the same for app subscriptions and per subscription of a checkout
session. 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 #### Discounts
A `discount` (e.g. a coupon) lowers the price of billed periods: every discounted period gets a A `discount` (e.g. a coupon) lowers the price of billed periods: every discounted period gets a
+41 -1
View File
@@ -383,10 +383,50 @@ $subscription = $user->subscriptions()->create([
``` ```
A few days before the trial ends, Anikeen ID sends `subscription.trial_will_end` 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. Cancelling during the trial (`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 bills nothing. Trials work the same for app subscriptions and per subscription of a checkout
session. 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 #### Discounts
A `discount` (e.g. a coupon) lowers the price of billed periods: every discounted period gets a A `discount` (e.g. a coupon) lowers the price of billed periods: every discounted period gets a
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Anikeen\Id\Enums;
/**
* `cancellation_reason` of a subscription (and `data.reason` of `subscription.cancelled`): why
* Anikeen ID's billing ended it. Null when the user, the app or an admin cancelled it.
*/
class CancellationReason
{
/** Its free trial (`trial_payment_method` optional) ended without a payment method: cancelled at the trial's end, nothing billed. */
const TRIAL_ENDED_WITHOUT_PAYMENT_METHOD = 'trial_ended_without_payment_method';
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Anikeen\Id\Enums;
/**
* `trial_payment_method` of a subscription with a free trial: whether the user needs a payment
* method to confirm it. Null on subscriptions without a trial.
*/
class TrialPaymentMethod
{
/** A payment method is needed to confirm the trial (the default). */
const REQUIRED = 'required';
/**
* The trial can be confirmed without a payment method (if nothing else has to be paid). If the
* user still has none when the trial ends, the subscription is cancelled then instead of billed
* (`cancellation_reason` CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD).
*/
const OPTIONAL = 'optional';
}
+3
View File
@@ -40,6 +40,7 @@ class AppCheckoutSessions
* trial_days?: null|int, * trial_days?: null|int,
* trial_ends_at?: null|string, * trial_ends_at?: null|string,
* trial_once?: bool, * 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} * 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,
@@ -51,6 +52,8 @@ class AppCheckoutSessions
* 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 * 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. * 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. * 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"
+2
View File
@@ -28,6 +28,8 @@ 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 ?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
+4
View File
@@ -52,6 +52,7 @@ class AppSubscriptions
* trial_days?: null|int, * trial_days?: null|int,
* trial_ends_at?: null|string, * trial_ends_at?: null|string,
* trial_once?: bool, * 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}, * 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
@@ -65,6 +66,9 @@ class AppSubscriptions
* trial_ends_at: a fixed end of the trial (ISO 8601, in the future), not both. * 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 * 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). * 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: * 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 * 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 * amount_off (net per period, at most the price), duration `once` (the first
+2
View File
@@ -27,6 +27,8 @@ 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 ?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)
*/ */
+2
View File
@@ -28,6 +28,7 @@ class Subscriptions extends BaseCollection
* trial_days?: null|int, * trial_days?: null|int,
* trial_ends_at?: null|string, * trial_ends_at?: null|string,
* trial_once?: bool, * 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} * 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)
@@ -43,6 +44,7 @@ class Subscriptions extends BaseCollection
* - trial_days: A free trial of 1–365 days from the user's confirmation on (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_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_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) * - discount: A discount per billed period, see AppSubscriptions::create() (optional)
* @throws Throwable * @throws Throwable
*/ */
+6 -1
View File
@@ -18,10 +18,15 @@ 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. */ /**
* 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'; 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. */ /** The last discounted period of a subscription was billed; the following ones are billed at the full price. */
+17
View File
@@ -4,6 +4,8 @@ namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId; use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable; use Anikeen\Id\Billable;
use Anikeen\Id\Enums\CancellationReason;
use Anikeen\Id\Enums\TrialPaymentMethod;
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppSubscription; use Anikeen\Id\Resources\AppSubscription;
use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\ConnectException;
@@ -188,6 +190,21 @@ class AppSubscriptionsTest extends TestCase
$this->assertTrue($subscription->isActive()); $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 public function test_a_used_trial_is_refused_on_checkout(): void
{ {
$this->respond(201, $this->subscription()); $this->respond(201, $this->subscription());