diff --git a/README.md b/README.md index 5dfa60d..709f742 100644 --- a/README.md +++ b/README.md @@ -361,8 +361,31 @@ same value as `status`): | `expired` | Paused and ran out; `resume()` starts a new period | no | | `cancelled` | Cancelled for good | no | -There are shortcuts for each: `isDraft()`, `isPaused()`, `isCancelling()`, `isExpired()`, -`isCancelled()`. App subscriptions (`appSubscriptions()`) have the same methods. +There are shortcuts for each: `isDraft()`, `isTrial()`, `isPaused()`, `isCancelling()`, +`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 @@ -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 `error` code (`already_active`, `already_cancelled`, `not_active`, `already_paused`, `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 try { diff --git a/README.stub b/README.stub index c87d48b..b03c161 100644 --- a/README.stub +++ b/README.stub @@ -361,8 +361,31 @@ same value as `status`): | `expired` | Paused and ran out; `resume()` starts a new period | no | | `cancelled` | Cancelled for good | no | -There are shortcuts for each: `isDraft()`, `isPaused()`, `isCancelling()`, `isExpired()`, -`isCancelled()`. App subscriptions (`appSubscriptions()`) have the same methods. +There are shortcuts for each: `isDraft()`, `isTrial()`, `isPaused()`, `isCancelling()`, +`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 @@ -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 `error` code (`already_active`, `already_cancelled`, `not_active`, `already_paused`, `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 try { diff --git a/src/Id/Concerns/HasSubscriptionStatus.php b/src/Id/Concerns/HasSubscriptionStatus.php index 2cddb04..f69aa1e 100644 --- a/src/Id/Concerns/HasSubscriptionStatus.php +++ b/src/Id/Concerns/HasSubscriptionStatus.php @@ -48,6 +48,14 @@ trait HasSubscriptionStatus 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). */ diff --git a/src/Id/Resources/AppCheckoutSessions.php b/src/Id/Resources/AppCheckoutSessions.php index 5c479f4..02ca49f 100644 --- a/src/Id/Resources/AppCheckoutSessions.php +++ b/src/Id/Resources/AppCheckoutSessions.php @@ -36,7 +36,10 @@ class AppCheckoutSessions * predecessor_id?: null|string, * vat_rate?: float, * 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, * 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 * 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. + * 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" */ diff --git a/src/Id/Resources/AppSubscriptions.php b/src/Id/Resources/AppSubscriptions.php index 777da58..35066cb 100644 --- a/src/Id/Resources/AppSubscriptions.php +++ b/src/Id/Resources/AppSubscriptions.php @@ -49,6 +49,9 @@ class AppSubscriptions * vat_rate?: float, * payload?: null|array, * ends_at?: null|string, + * trial_days?: null|int, + * trial_ends_at?: null|string, + * trial_once?: bool, * 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. @@ -56,8 +59,13 @@ class AppSubscriptions * at most 2 decimals, not below price; null = no cap). * 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. + * 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 { diff --git a/src/Id/Resources/Subscriptions.php b/src/Id/Resources/Subscriptions.php index 9b2a0f9..7cd5305 100644 --- a/src/Id/Resources/Subscriptions.php +++ b/src/Id/Resources/Subscriptions.php @@ -24,7 +24,10 @@ class Subscriptions extends BaseCollection * predecessor_id?: null|string, * vat_rate: float, * 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: * - group: The group (optional) * - name: The name @@ -36,6 +39,9 @@ class Subscriptions extends BaseCollection * - vat_rate: The VAT rate (required when set) * - payload: The payload (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 */ public function create(array $attributes): Subscription diff --git a/src/Id/Webhooks/EventType.php b/src/Id/Webhooks/EventType.php index e4b8b44..3fcd1af 100644 --- a/src/Id/Webhooks/EventType.php +++ b/src/Id/Webhooks/EventType.php @@ -21,6 +21,9 @@ final class EventType 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. */ + 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). */ public const CHECKOUT_COMPLETED = 'checkout.completed'; diff --git a/tests/Id/AppSubscriptionsTest.php b/tests/Id/AppSubscriptionsTest.php index 53e41ef..4132ff0 100644 --- a/tests/Id/AppSubscriptionsTest.php +++ b/tests/Id/AppSubscriptionsTest.php @@ -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 { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Id/CheckoutSessionsTest.php b/tests/Id/CheckoutSessionsTest.php index 06635f8..5b38e78 100644 --- a/tests/Id/CheckoutSessionsTest.php +++ b/tests/Id/CheckoutSessionsTest.php @@ -139,6 +139,20 @@ class CheckoutSessionsTest extends TestCase $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 { $this->owner()->checkoutSessions(); @@ -159,5 +173,6 @@ class CheckoutSessionsTest extends TestCase $this->assertSame(['01JS1', '01JS2'], $event['data']['object']['subscription_ids']); $this->assertContains('checkout.expired', EventType::all()); $this->assertContains('subscription.activated', EventType::all()); + $this->assertContains('subscription.trial_will_end', EventType::all()); } } diff --git a/tests/Id/SubscriptionStatusTest.php b/tests/Id/SubscriptionStatusTest.php index ddeca9c..25eba66 100644 --- a/tests/Id/SubscriptionStatusTest.php +++ b/tests/Id/SubscriptionStatusTest.php @@ -47,6 +47,7 @@ class SubscriptionStatusTest extends TestCase $this->assertSame($status, $resource->status()); $this->assertSame($running, $resource->isActive()); $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::CANCELLING, $resource->isCancelling()); $this->assertSame($status === SubscriptionStatus::EXPIRED, $resource->isExpired());