mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-04 22:59:41 +00:00
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.
This commit is contained in:
10 files changed
+104
-2
No files matched your search
@@ -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
|
||||
|
||||
+31
@@ -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
|
||||
|
||||
@@ -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"
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", …
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user