From bf21142f720438ad34c08c01b5f1bcfca8ee3fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Wed, 4 Nov 2026 21:24:22 +0100 Subject: [PATCH] Discount subscriptions per billed period discount (label, percent_off or amount_off, duration once, repeating or forever) when creating subscriptions and per subscription of checkout sessions, the discount field on subscriptions and the subscription.discount_ended event type. --- README.md | 31 ++++++++++++++++++++++++ README.stub | 31 ++++++++++++++++++++++++ src/Id/Resources/AppCheckoutSessions.php | 4 ++- src/Id/Resources/AppSubscription.php | 1 + src/Id/Resources/AppSubscriptions.php | 6 +++++ src/Id/Resources/Subscription.php | 1 + src/Id/Resources/Subscriptions.php | 4 ++- src/Id/Webhooks/EventType.php | 3 +++ tests/Id/AppSubscriptionsTest.php | 12 +++++++++ tests/Id/CheckoutSessionsTest.php | 13 ++++++++++ 10 files changed, 104 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 709f742..21e45b4 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,37 @@ A few days before the trial ends, Anikeen ID sends `subscription.trial_will_end` bills nothing. Trials work the same for app subscriptions and per subscription of a checkout session. +#### 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 Anikeen ID sends `order.*`, `subscription.*` and `checkout.*` events to the webhook endpoints of diff --git a/README.stub b/README.stub index b03c161..fc74eb8 100644 --- a/README.stub +++ b/README.stub @@ -387,6 +387,37 @@ A few days before the trial ends, Anikeen ID sends `subscription.trial_will_end` bills nothing. Trials work the same for app subscriptions and per subscription of a checkout session. +#### 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 Anikeen ID sends `order.*`, `subscription.*` and `checkout.*` events to the webhook endpoints of diff --git a/src/Id/Resources/AppCheckoutSessions.php b/src/Id/Resources/AppCheckoutSessions.php index 02ca49f..1deaa1f 100644 --- a/src/Id/Resources/AppCheckoutSessions.php +++ b/src/Id/Resources/AppCheckoutSessions.php @@ -39,7 +39,8 @@ class AppCheckoutSessions * ends_at?: null|string, * trial_days?: null|int, * trial_ends_at?: null|string, - * trial_once?: bool + * trial_once?: bool, + * discount?: null|array{label: string, percent_off?: float|string, amount_off?: float|string, duration: string, duration_in_periods?: int} * }>, * return_url?: null|string, * metadata?: null|array, @@ -50,6 +51,7 @@ 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. + * 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 351f120..5ea2150 100644 --- a/src/Id/Resources/AppSubscription.php +++ b/src/Id/Resources/AppSubscription.php @@ -28,6 +28,7 @@ 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 ?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 * @property ?string $accounting_at diff --git a/src/Id/Resources/AppSubscriptions.php b/src/Id/Resources/AppSubscriptions.php index 35066cb..2c5a336 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, + * 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 * subscription created by the first one instead of a duplicate. @@ -64,6 +65,11 @@ 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). + * 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", … */ diff --git a/src/Id/Resources/Subscription.php b/src/Id/Resources/Subscription.php index b26e2c2..6a8d04c 100644 --- a/src/Id/Resources/Subscription.php +++ b/src/Id/Resources/Subscription.php @@ -27,6 +27,7 @@ use Throwable; * @property string $ends_at * @property ?string $cancels_at when a scheduled cancellation takes effect * @property ?string $trial_ends_at + * @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) */ class Subscription extends BaseResource diff --git a/src/Id/Resources/Subscriptions.php b/src/Id/Resources/Subscriptions.php index 7cd5305..35e4cec 100644 --- a/src/Id/Resources/Subscriptions.php +++ b/src/Id/Resources/Subscriptions.php @@ -27,7 +27,8 @@ class Subscriptions extends BaseCollection * ends_at: null|string, * trial_days?: null|int, * trial_ends_at?: null|string, - * trial_once?: bool + * trial_once?: bool, + * 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) * - name: The name @@ -42,6 +43,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) + * - discount: A discount per billed period, see AppSubscriptions::create() (optional) * @throws Throwable */ public function create(array $attributes): Subscription diff --git a/src/Id/Webhooks/EventType.php b/src/Id/Webhooks/EventType.php index 3fcd1af..77208f2 100644 --- a/src/Id/Webhooks/EventType.php +++ b/src/Id/Webhooks/EventType.php @@ -24,6 +24,9 @@ final class EventType /** The free trial of a running subscription ends in a few days (see trial_ends_at); billing starts then. */ 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 user confirmed a checkout session: all of its subscriptions are running (sent after their subscription.activated). */ public const CHECKOUT_COMPLETED = 'checkout.completed'; diff --git a/tests/Id/AppSubscriptionsTest.php b/tests/Id/AppSubscriptionsTest.php index 4132ff0..fbf1111 100644 --- a/tests/Id/AppSubscriptionsTest.php +++ b/tests/Id/AppSubscriptionsTest.php @@ -203,6 +203,18 @@ class AppSubscriptionsTest extends TestCase } } + 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_missing_user_id_is_reported(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Id/CheckoutSessionsTest.php b/tests/Id/CheckoutSessionsTest.php index 5b38e78..764c3a5 100644 --- a/tests/Id/CheckoutSessionsTest.php +++ b/tests/Id/CheckoutSessionsTest.php @@ -153,6 +153,18 @@ class CheckoutSessionsTest extends TestCase $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 { $this->owner()->checkoutSessions(); @@ -174,5 +186,6 @@ class CheckoutSessionsTest extends TestCase $this->assertContains('checkout.expired', EventType::all()); $this->assertContains('subscription.activated', EventType::all()); $this->assertContains('subscription.trial_will_end', EventType::all()); + $this->assertContains('subscription.discount_ended', EventType::all()); } }