mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-04 22:59:41 +00:00
Start subscriptions with a free trial
trial_days, trial_ends_at and trial_once when creating subscriptions (app and user token) and per subscription of checkout sessions. isTrial() on subscriptions, the subscription.trial_will_end event type, and the trial_already_used error code in the docs.
This commit is contained in:
10 files changed
+130
-9
No files matched your search
@@ -361,8 +361,31 @@ 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()`, `isPaused()`, `isCancelling()`, `isExpired()`,
|
There are shortcuts for each: `isDraft()`, `isTrial()`, `isPaused()`, `isCancelling()`,
|
||||||
`isCancelled()`. App subscriptions (`appSubscriptions()`) have the same methods.
|
`isExpired()`, `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. Cancelling during the trial
|
||||||
|
bills nothing. Trials work the same for app subscriptions and per subscription of a checkout
|
||||||
|
session.
|
||||||
|
|
||||||
### Webhooks
|
### Webhooks
|
||||||
|
|
||||||
@@ -508,7 +531,7 @@ subscriptions it created (plus those of clients delegated to it).
|
|||||||
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`) and `isNotFound()`, `isConflict()`, `isTemporary()`:
|
`idempotency_key_conflict`, `in_checkout_session`, `trial_already_used`) and `isNotFound()`, `isConflict()`, `isTemporary()`:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
try {
|
try {
|
||||||
|
|||||||
+26
-3
@@ -361,8 +361,31 @@ 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()`, `isPaused()`, `isCancelling()`, `isExpired()`,
|
There are shortcuts for each: `isDraft()`, `isTrial()`, `isPaused()`, `isCancelling()`,
|
||||||
`isCancelled()`. App subscriptions (`appSubscriptions()`) have the same methods.
|
`isExpired()`, `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. Cancelling during the trial
|
||||||
|
bills nothing. Trials work the same for app subscriptions and per subscription of a checkout
|
||||||
|
session.
|
||||||
|
|
||||||
### Webhooks
|
### Webhooks
|
||||||
|
|
||||||
@@ -508,7 +531,7 @@ subscriptions it created (plus those of clients delegated to it).
|
|||||||
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`) and `isNotFound()`, `isConflict()`, `isTemporary()`:
|
`idempotency_key_conflict`, `in_checkout_session`, `trial_already_used`) and `isNotFound()`, `isConflict()`, `isTemporary()`:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ 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).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ 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
|
||||||
* }>,
|
* }>,
|
||||||
* return_url?: null|string,
|
* return_url?: null|string,
|
||||||
* metadata?: null|array,
|
* metadata?: null|array,
|
||||||
@@ -45,6 +48,8 @@ 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.
|
||||||
*
|
*
|
||||||
* @throws ApiException 422 validation (e.g. return_url), 409 "idempotency_key_conflict"
|
* @throws ApiException 422 validation (e.g. return_url), 409 "idempotency_key_conflict"
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ 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,
|
||||||
* 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.
|
||||||
@@ -56,8 +59,13 @@ 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).
|
||||||
*
|
*
|
||||||
* @throws ApiException
|
* @throws ApiException 422 validation, 409 "idempotency_key_conflict", …
|
||||||
*/
|
*/
|
||||||
public function create(array $attributes): AppSubscription
|
public function create(array $attributes): AppSubscription
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ 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
|
||||||
* } $attributes The subscription data:
|
* } $attributes The subscription data:
|
||||||
* - group: The group (optional)
|
* - group: The group (optional)
|
||||||
* - name: The name
|
* - name: The name
|
||||||
@@ -36,6 +39,9 @@ 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)
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
public function create(array $attributes): Subscription
|
public function create(array $attributes): Subscription
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ final class EventType
|
|||||||
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. */
|
||||||
|
public const SUBSCRIPTION_TRIAL_WILL_END = 'subscription.trial_will_end';
|
||||||
|
|
||||||
/** 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';
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,35 @@ 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_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_missing_user_id_is_reported(): void
|
public function test_a_missing_user_id_is_reported(): void
|
||||||
{
|
{
|
||||||
$this->expectException(InvalidArgumentException::class);
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|||||||
@@ -139,6 +139,20 @@ 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_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();
|
||||||
@@ -159,5 +173,6 @@ 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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,7 @@ 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());
|
||||||
|
|||||||
Reference in new issue
Block a user