Subscriptions
Checkout, trials, checking status, paid pages, cancelling and resuming
# Subscriptions
A subscription is made on Stripe's Checkout page, lives in Stripe, and is
copied into `stripe_subscriptions` by the webhook each time it changes.
Your app asks the copy.
## Starting one with Checkout
```pascal
uses Askr.Plugin.Stripe.Billing;
function Subscribe(Req: TRequest): TResponse;
var
C: TCheckout;
begin
C := SubscriptionCheckout('price_pro_monthly',
AppUrl + '/billing/thanks', { after paying }
AppUrl + '/pricing'); { the back link on Checkout }
Result := Redirect(CheckoutUrl(Askr.Auth.Id, UserEmail, C), 303);
end;
```
`CheckoutUrl` makes the user's Stripe customer if there is none, makes a
Checkout session for them, and returns the page to send them to. The
session carries the user id as `client_reference_id` and in the
subscription's metadata, so the subscription it makes can always be found
again.
`TCheckout` is a record: set its fields before `CheckoutUrl`.
| Field | |
|---|---|
| `C.Add(Price, Quantity)` | Another price on the same subscription — a base plan and seats, say |
| `C.TrialDays` | A trial of that many days before the first charge |
| `C.AllowPromotionCodes` | Shows Stripe's promotion code field on the page |
| `C.IdempotencyKey` | A retry with the same key returns the same session instead of a second one |
| `C.Extra` | Anything else Checkout takes, in Stripe's names |
### Several prices and quantities
```pascal
C := SubscriptionCheckout('price_team_base', OkUrl, BackUrl);
C.Add('price_team_seat', Seats);
```
Each price becomes an item on one subscription, and every one is in
`stripe_subscription_items` — `Subscribed(User, 'price_team_seat')` is
true as well as `Subscribed(User, 'price_team_base')`.
### Trials
```pascal
C.TrialDays := 14;
```
Checkout takes a card up front and charges nothing until the trial ends.
To start a trial without asking for a card, add Stripe's own parameter:
```pascal
C.TrialDays := 14;
C.Extra.Add('payment_method_collection', 'if_required');
```
A trial without a card ends in `past_due` or cancelled when it runs out,
depending on the subscription's settings in the dashboard; Stripe emails
the customer to add one if you have turned that on.
A trial belongs to a subscription. `TrialDays` on a payment checkout
raises before anything is sent.
### Anything else
`C.Extra` takes any parameter Stripe's
[Checkout reference](https://docs.stripe.com/api/checkout/sessions/create)
lists, and sends it after the fields above:
```pascal
C.Extra.Add('billing_address_collection', 'required');
C.Extra.Add('subscription_data[description]', 'Pro plan');
C.Extra.Add('locale', 'nb');
```
The plugin has fields only for what most apps set. A field for each of
Checkout's parameters would be a second copy of Stripe's reference, and it
would always be behind it.
## Checking a subscription
| | |
|---|---|
| `Subscribed(UserId)` | A subscription that is active or trialing |
| `Subscribed(UserId, Price)` | ... with that price among its items |
| `OnTrial(UserId)` | Trialing, and the trial has not ended |
| `OnGracePeriod(UserId)` | Cancelled, and still running to the end of what was paid for |
| `FindSubscription(UserId, S)` | The subscription itself |
```pascal
var
S: TStripeSubscription;
begin
if FindSubscription(Askr.Auth.Id, S) then
{ S.StripeId, S.Status, S.Price, S.Prices, S.ItemIds, S.Quantity,
S.TrialEnd, S.CurrentPeriodEnd, S.CancelAtPeriodEnd, S.CancelAt,
S.EndedAt, S.Valid }
end;
```
`FindSubscription` gives the user's valid subscription if there is one,
and otherwise the newest. The times are Stripe's unix seconds, and 0 means
none.
What each of Stripe's statuses means here:
| Status | `Subscribed` | |
|---|---|---|
| `trialing` | yes | In a trial. `OnTrial` is true until `TrialEnd` |
| `active` | yes | Paid. Also through the grace period after cancelling |
| `past_due` | no | A renewal failed and Stripe is retrying it |
| `incomplete` | no | The first payment failed or needs 3-D Secure |
| `incomplete_expired` | no | ... and was not completed within 23 hours |
| `unpaid` | no | Stripe gave up retrying |
| `canceled` | no | Ended |
| `paused` | no | A trial ended without a card, set to pause |
`past_due` locks a user out at once. Whether it should is your decision:
many apps keep access for the days Stripe retries. Ask for it yourself:
```pascal
Paid := Subscribed(Id) or (FindSubscription(Id, S) and (S.Status = 'past_due'));
```
**The row's own times answer when a webhook is late.** A trial whose end
has passed is not a trial, and a grace period whose end has passed is
not a grace period, even before the webhook that ends them arrives.
## Pages for subscribers
A router's middleware covers every route, so a paid page asks for itself,
the way a page that needs a verified address does:
```pascal
function Reports(Req: TRequest): TResponse;
begin
Result := RequireSubscribed('price_pro_monthly', '/pricing');
if Result <> nil then
Exit;
Result := RespondHtml(RenderReports);
end;
```
`RequireSubscribed(Price, PricingPath)` is `nil` when the signed-in user
has a valid subscription — to that price, when one is given — and
otherwise the answer to send:
| Client | Answer |
|---|---|
| A browser | `303` to `PricingPath`, `/pricing` unless given |
| An Inertia visit | `409` with `X-Inertia-Location`, which Inertia follows as a full visit |
| A JSON client | `402` with a problem document |
**Nobody signed in is refused too.** `RequireVerified` leaves that case to
`RequireAuth`; this does not, because a paid page that opened because
someone forgot `RequireAuth` is a paid page given away.
## Cancelling
```pascal
CancelSubscription(UserId); { at the end of the paid period }
ResumeSubscription(UserId); { take that back, while it still runs }
CancelSubscriptionNow(UserId); { end it now }
```
`CancelSubscription` sets `cancel_at_period_end`: the user keeps what they
paid for, `Subscribed` stays true and `OnGracePeriod` becomes true, and
when the period ends Stripe ends the subscription and the webhook marks
it `canceled`. `ResumeSubscription` takes the cancellation back as long
as the period has not ended. `CancelSubscriptionNow` ends it at once,
and by default Stripe neither refunds nor credits the unused part of the
period — a refund is yours to make, in the dashboard or through the API.
Each applies Stripe's reply to your table straight away, so the next page
shows the change; the webhook for the same change arrives a moment later
and changes nothing further. Each raises an `EStripeError` when the user
has no valid subscription, before asking Stripe.
The portal can do all three, when it is allowed in its settings, and
most apps let it.
## Changing a plan
Moving a subscriber from one price to another is the portal's, in 0.1:
turn on "Customers can switch plans" in the portal settings and list the
prices they may switch between. The plan change comes back as
`customer.subscription.updated`, and `Subscribed(User, NewPrice)` is true
from then.
From code, it is Stripe's API. Stripe changes an item by its `si_...` id,
and `S.ItemIds` has them, in the same order as `S.Prices`:
```pascal
var
S: TStripeSubscription;
P: TStripeParams;
begin
if not FindSubscription(UserId, S) or not S.Valid then
Exit;
P := Default(TStripeParams);
P.Add('items[0][id]', S.ItemIds[0]);
P.Add('items[0][price]', 'price_pro_yearly');
P.Add('proration_behavior', 'create_prorations');
Stripe.Post('/v1/subscriptions/' + S.StripeId, P);
end;
```
The table changes when the webhook for it arrives, a moment later.
A `SwapPrice` of the plugin's own is the obvious next function, and not
in 0.1: which proration an app wants is a question with several right
answers, and a helper that picks one would be wrong for the others.