Subscriptions
Payable manages the full subscription lifecycle: create, swap the price, change quantity, cancel at period end (grace period), cancel immediately, and resume. Creation runs through a builder; all post-creation operations run through a manager. Every operation persists the new state locally after the provider confirms it.
Two entry points
| Goal | Entry point | Class |
|---|---|---|
| Create a subscription | payable.customer(billable).newSubscription(name) | SubscriptionBuilder |
| Manage an existing one | payable.customer(billable).subscription(name) | SubscriptionManager |
The name is the local subscription name (for example 'default'). It scopes the subscription per
customer: FindSubscriptionQuery looks it up with storage.subscriptions.findByName(customerId, name).
Creating a subscription
SubscriptionBuilder collects state fluently, then create() runs CreateSubscriptionAction.
| Method | Effect |
|---|---|
price(priceId) | Primary price. Required before create(). |
addItem(priceId, qty) | Extra line item (default qty 1). |
trialDays(days) | Trial length. |
coupon(code) | Coupon code. |
quantity(qty) | Primary line-item quantity (default 1). |
const subscription = await payable
.customer(billable)
.newSubscription('default')
.price('price_pro')
.trialDays(14)
.coupon('LAUNCH')
.addItem('price_seats', 5)
.create();
CreateSubscriptionAction:
- Requires the provider to be direct-subscription capable (
isDirectSubscriptionCapable, i.e. it implementscreateSubscription); otherwise throwsProviderCapabilityNotSupportedError. - Requires a storage driver (inherited from
SubscriptionAction.storage(),SUBSCRIPTION_STORAGE_REQUIRED). - Syncs the customer to the provider (
SyncCustomerWithProviderAction) and loads the local customer row; throwsCustomerNotFoundErrorif missing. - Calls
provider.createSubscription({ providerCustomerId, priceId, quantity, items, trialDays, coupon }, ctx)with keyIdempotencyKey.forSubscription(subscription:create:...keyed by billable + name + price). - In a storage transaction, persists the
subscriptionsrow and onesubscription_itemsrow per line item. WhenaddItem(...)was used, the items array is the primary price followed by the additional items; otherwise it is a single primary item.
The persisted subscription captures status, priceId, quantity (default 1), trialEndsAt, and
currentPeriodEnd from the provider DTO; endsAt and currentPeriodStart start as null.
sequenceDiagram
participant App
participant Builder as SubscriptionBuilder
participant Action as CreateSubscriptionAction
participant Sync as SyncCustomerWithProviderAction
participant Provider
participant Storage
App->>Builder: price().trialDays().create()
Builder->>Action: handle(CreateSubscriptionInputData)
Action->>Action: assert direct-subscription capable + storage
Action->>Sync: handle(billable)
Sync-->>Action: providerCustomerId
Action->>Storage: customers.findByBillable
Storage-->>Action: customer (or CustomerNotFoundError)
Action->>Provider: createSubscription(input, ctx)
Provider-->>Action: SubscriptionDTO
Action->>Storage: transaction(create subscription + items)
Storage-->>App: Subscription
SubscriptionBuilder can alternatively call checkout(urls) to start the subscription through a
provider-hosted page instead of creating it directly - see 09-checkout.
Managing a subscription
SubscriptionManager wraps one action per operation. They all extend SubscriptionAction, which:
- requires a storage driver (
SUBSCRIPTION_STORAGE_REQUIRED), - asserts the provider’s
subscriptionscapability viaassertProviderCapability, - resolves the local subscription by name (
SubscriptionNotFoundErrorif missing or unmapped), - builds a deterministic idempotency key per operation
(
subscription:${operation}:${providerName}:${providerSubscriptionId}[:discriminator]).
Swap - subscription(name).swap(priceId)
SwapSubscriptionAction calls provider.updateSubscription({ providerSubscriptionId, priceId }),
then updates the local priceId and status, and updates the primary subscription item’s price.
Use to move a customer between plans.
Update quantity - subscription(name).updateQuantity(qty)
UpdateSubscriptionQuantityAction calls provider.updateSubscription({ providerSubscriptionId, quantity }),
then updates the local quantity and status and the primary item’s quantity. The idempotency key
includes the quantity as a discriminator, so each distinct quantity gets its own key.
Cancel (grace period) - subscription(name).cancel()
CancelSubscriptionAction calls provider.cancelSubscription({ providerSubscriptionId, immediately: false }),
then sets the local status and endsAt = dto.currentPeriodEnd. The subscription stays usable until
that date - this is the grace period. onGracePeriod(subscription, now) returns true while
endsAt is in the future.
Cancel now - subscription(name).cancelNow()
CancelSubscriptionNowAction calls cancelSubscription({ ..., immediately: true }), then sets
status and endsAt = clock.now(). There is no grace period; the subscription ends immediately. The
canceled-now subscription has status: 'canceled' and endsAt equal to the current clock time.
Resume - subscription(name).resume()
ResumeSubscriptionAction calls provider.resumeSubscription({ providerSubscriptionId }), then sets
status and clears endsAt = null. Resuming is meaningful for a subscription that was canceled with
grace (still within its period); clearing endsAt takes it back off the grace period. Resuming a
grace-period subscription sets endsAt back to null.
const manager = payable.customer(billable).subscription('default');
await manager.swap('price_business');
await manager.updateQuantity(3);
await manager.cancel(); // ends at period end (grace period)
await manager.resume(); // clears endsAt
await manager.cancelNow(); // ends immediately
Cancel vs cancel-now vs resume
| Operation | Provider call | Local endsAt | Customer access |
|---|---|---|---|
cancel() | immediately: false | currentPeriodEnd | Retained until period end (grace) |
cancelNow() | immediately: true | clock.now() | Ends immediately |
resume() | resumeSubscription | null | Restored |
State helpers
Pure predicates over a stored subscription:
onTrial(subscription, now)-trialEndsAtin the future.onGracePeriod(subscription, now)-endsAtin the future.subscriptionEnded(subscription, now)-endsAtin the past or now.
For the underlying status transitions (trialing, active, canceled, …) see
07-state-machines.
Policies
CanCreateSubscriptionPolicy, CanCancelSubscriptionPolicy, and CanResumeSubscriptionPolicy
authorize against an AuthorizationContext. As of this version they are not wired into the
subscription actions - no action references them. They are available building blocks; integrators
enforce authorization in their own layer. Only CanReplayWebhookPolicy is used by an action - see
13-webhooks.
Edge cases
- No storage driver. Any management operation throws
PayableError(...requires a storage driver). - Provider lacks
subscriptionscapability.assertProviderCapabilitythrowsProviderCapabilityNotSupportedError. - Provider not direct-subscription capable on create.
CreateSubscriptionActionthrows before any provider call. - Unknown subscription name.
resolve()throwsSubscriptionNotFoundError. - Customer row missing on create.
CustomerNotFoundErrorafter sync (defensive; sync normally creates the row). - Subscription-mode checkout vs direct create.
newSubscription(...).checkout(urls)forwards only the primary price; multi-item plans needcreate()withaddItem(...).