diff --git a/README.md b/README.md index 29d25cf..044cd69 100644 --- a/README.md +++ b/README.md @@ -383,10 +383,50 @@ $subscription = $user->subscriptions()->create([ ``` 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 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 diff --git a/README.stub b/README.stub index aeabc19..e9e2303 100644 --- a/README.stub +++ b/README.stub @@ -383,10 +383,50 @@ $subscription = $user->subscriptions()->create([ ``` 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 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 diff --git a/src/Id/Enums/CancellationReason.php b/src/Id/Enums/CancellationReason.php new file mode 100644 index 0000000..94ff94a --- /dev/null +++ b/src/Id/Enums/CancellationReason.php @@ -0,0 +1,13 @@ +, * return_url?: null|string, @@ -51,6 +52,8 @@ class AppCheckoutSessions * 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" diff --git a/src/Id/Resources/AppSubscription.php b/src/Id/Resources/AppSubscription.php index 3c7f622..c6e91a0 100644 --- a/src/Id/Resources/AppSubscription.php +++ b/src/Id/Resources/AppSubscription.php @@ -28,6 +28,8 @@ use JsonSerializable; * @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 $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 $ends_at diff --git a/src/Id/Resources/AppSubscriptions.php b/src/Id/Resources/AppSubscriptions.php index 2c5a336..433f6c2 100644 --- a/src/Id/Resources/AppSubscriptions.php +++ b/src/Id/Resources/AppSubscriptions.php @@ -52,6 +52,7 @@ class AppSubscriptions * 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 * } $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_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 diff --git a/src/Id/Resources/Subscription.php b/src/Id/Resources/Subscription.php index 6a8d04c..b4b4245 100644 --- a/src/Id/Resources/Subscription.php +++ b/src/Id/Resources/Subscription.php @@ -27,6 +27,8 @@ use Throwable; * @property string $ends_at * @property ?string $cancels_at when a scheduled cancellation takes effect * @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) */ diff --git a/src/Id/Resources/Subscriptions.php b/src/Id/Resources/Subscriptions.php index 35e4cec..a7d569e 100644 --- a/src/Id/Resources/Subscriptions.php +++ b/src/Id/Resources/Subscriptions.php @@ -28,6 +28,7 @@ class Subscriptions extends BaseCollection * 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: * - 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_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 */ diff --git a/src/Id/Webhooks/EventType.php b/src/Id/Webhooks/EventType.php index dd9a83a..06d5f21 100644 --- a/src/Id/Webhooks/EventType.php +++ b/src/Id/Webhooks/EventType.php @@ -18,10 +18,15 @@ final class EventType public const SUBSCRIPTION_ACTIVATED = 'subscription.activated'; public const SUBSCRIPTION_PAUSED = 'subscription.paused'; 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_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'; /** The last discounted period of a subscription was billed; the following ones are billed at the full price. */ diff --git a/tests/Id/AppSubscriptionsTest.php b/tests/Id/AppSubscriptionsTest.php index c9717e4..539379c 100644 --- a/tests/Id/AppSubscriptionsTest.php +++ b/tests/Id/AppSubscriptionsTest.php @@ -4,6 +4,8 @@ namespace Anikeen\Id\Tests; use Anikeen\Id\AnikeenId; use Anikeen\Id\Billable; +use Anikeen\Id\Enums\CancellationReason; +use Anikeen\Id\Enums\TrialPaymentMethod; use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Resources\AppSubscription; use GuzzleHttp\Exception\ConnectException; @@ -188,6 +190,21 @@ class AppSubscriptionsTest extends TestCase $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());