diff --git a/README.md b/README.md index c9ca5b4..c03eb4e 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -# @antelopejs/interface-subscriptions +# `@antelopejs/interface-subscriptions`
-npm -License -Discord -Docs +npm +License +Discord +Docs
-Provider-agnostic subscriptions for AntelopeJS. Bill a saved payment method -every period — with trials, a billing anchor, cancellation now or at period end, -and `past_due` reporting — without naming a provider, and without every -consumer rebuilding a billing cycle of its own. +The AntelopeJS interface for subscriptions that work with any payment provider. +Bill a saved payment method every period, with trials, a billing anchor, +cancellation now or at period end, and `past_due` reporting. Your code names no +provider, and no consumer has to build a billing cycle of its own. ## Installation @@ -21,19 +21,20 @@ pnpm add @antelopejs/interface-subscriptions This package declares the contract and ships no billing logic. Wire a module that implements it: -- a provider that fires renewals itself implements it natively (Stripe, PayPal, - Mollie); -- for any other `@antelopejs/interface-payment` provider, wire - `@antelopejs/payment-subscriptions`, which runs the renewal clock itself on +- A payment module whose provider fires renewals itself implements it natively + (Stripe, PayPal, Mollie). +- For any other `@antelopejs/interface-payment` provider, wire + `@antelopejs/payment-subscriptions`. It runs the renewal clock itself, with saved methods and off-session charges. -A payment module that cannot do subscriptions simply does not implement this -interface. There is no half-honoured contract and no `UnsupportedOperationError` -here. +A payment module that cannot do subscriptions does not implement this +interface. No implementation keeps only part of the contract, and the interface +has no `UnsupportedOperationError`. -For Stripe, native subscriptions in `@antelopejs/stripe` are recommended. -`@antelopejs/payment-subscriptions` is an explicit alternative, not an automatic -fallback. Wire one renewal owner per subscription, never both. +For Stripe, use the native subscriptions in `@antelopejs/stripe`. +`@antelopejs/payment-subscriptions` is an alternative you choose explicitly. It +does not take over automatically. Wire one renewal owner per subscription, never +both. ## Choose a task @@ -42,10 +43,10 @@ fallback. Wire one renewal owner per subscription, never both. - Implement a billing provider: [implementation guide](./docs/4.implementing.md). - Choose native or generic billing: [provider capabilities](./docs/5.provider-capability-matrix.md). -Replay protection lasts only for the provider-defined replay window. Persist -operation keys, subscription IDs and payment IDs. After expiration, applications -must reconcile uncertain payments with provider records before retrying; reusing -a key is not indefinite duplicate-charge protection. +Replay protection lasts only for the replay window that the provider defines. +Persist operation keys, subscription IDs, and payment IDs. After the window +expires, reconcile uncertain payments with the provider records before you +retry. A reused key does not protect against duplicate charges forever. ## What it covers @@ -56,12 +57,13 @@ a key is not indefinite duplicate-charge protection. | Recovery | `UpdateSubscriptionPaymentMethod` | | Events | `SubscriptionEvents` (a registering proxy, not a webhook route) | -The portable core is small on purpose: amount, interval and interval count, -billing anchor, trial end, status including `past_due`, cancel now or at period -end, and the current period. Proration, dunning schedules, tax, coupons, metered -billing and multiple items per subscription are out, because the generic -fallback cannot honour them and a contract that only some implementations keep -is a lie. For those, use the provider's own interface. +The portable core is small on purpose. It covers the amount, the interval and +interval count, the billing anchor, the trial end, the status including +`past_due`, cancellation now or at period end, and the current period. +Proration, dunning schedules, tax, coupons, metered billing, and multiple items +per subscription are out. The generic fallback cannot honour them, and a +contract that only some implementations keep misleads its callers. For those +features, use the provider's own interface. ## Quick start @@ -104,7 +106,7 @@ export async function startSignup( export async function finishSignup(paymentId: string, accountId: string) { const payment = await GetPayment(paymentId); if (payment.status !== "succeeded" && payment.status !== "authorized") { - return undefined; // Still pending/action required, or failed/canceled: do not subscribe. + return undefined; // pending, requires_action, failed, or canceled: do not subscribe. } if (!payment.customer) throw new Error("Signup payment has no customer"); @@ -125,18 +127,20 @@ export async function finishSignup(paymentId: string, accountId: string) { } ``` -Use an existing payment-interface customer and a provider-issued method token. -Persist a unique `signupId` and the returned `paymentId` against the authenticated -account. Replace the return URL with your own route and send the payer to -`redirectUrl` when action is required. If the payment is pending, wait for a -verified `payment.succeeded` or `payment.authorized` webhook before retrying -`finishSignup`; failed or canceled payments must not create subscriptions. - -Call `finishSignup` with the saved payment ID and account ID, using the same -provider account throughout. The saved-method payment and the first subscription -period are separate charges (€1 then €19 here); the first period is charged -off-session before `CreateSubscription` resolves. Adapt those amounts to your -billing policy. Register an idempotent handler for subsequent lifecycle events: +Use an existing payment-interface customer and a method token that the +provider issued. Persist a unique `signupId` and the returned `paymentId` +against the authenticated account. Replace the return URL with your own route. +When action is required, send the payer to `redirectUrl`. If the payment is +pending, wait for a verified `payment.succeeded` or `payment.authorized` webhook +before you retry `finishSignup`. A failed or canceled payment must not create a +subscription. + +Call `finishSignup` with the saved payment ID and the account ID, and use the +same provider account throughout. The payment that saves the method and the +first subscription period are separate charges, €1 then €19 here. The +implementation charges the first period off-session before `CreateSubscription` +resolves. Adapt those amounts to your billing policy. Register an idempotent +handler for later lifecycle events: ```ts import { @@ -151,8 +155,9 @@ SubscriptionEvents.register("billing", async (event) => { }); ``` -`grantOrRevokeAccess` stands for your application's access policy, and its `declare` line only gives it a type. Deduplicate -events by `event.id` as described in [Events](./docs/3.events.md). +`grantOrRevokeAccess` stands for your application's access policy, and its +`declare` line only gives it a type. Deduplicate events by `event.id`, as +[Events](./docs/3.events.md) describes. ## Compatibility @@ -165,36 +170,40 @@ events by `event.id` as described in [Events](./docs/3.events.md). -These are the peer requirements in `package.json`. Development uses the same -payment-interface semver range. Check your implementing module's requirements -too. `@antelopejs/core` is a development test runner, not a runtime peer. +The table lists the peer requirements in `package.json`. Development uses the +same semver range for the payment interface. Check the requirements of your +implementing module too. `@antelopejs/core` is a test runner for development, +not a runtime peer. ## Conventions -- **Built on the payment interface's portable pair**: one on-session payment that - saves a method, then off-session charges. Customers, methods and payments are - `@antelopejs/interface-payment` identifiers from the same account. -- **An idempotency key on every mutating call**, positionally. +- **Built on the portable pair of the payment interface.** One on-session + payment saves a method, then off-session charges use it. Customers, methods, + and payments are `@antelopejs/interface-payment` identifiers from the same + account. +- **An idempotency key on every mutating call.** It is a positional argument. - **A declined renewal is a status, not an exception.** Only a declined _first_ period rejects, with `SubscriptionPaymentError`, and creates nothing. -- **`past_due` is portable to report; the retry policy is not.** +- **`past_due` is portable to report.** The retry policy is not portable. - **`vendorData` is a read-only echo of JSON values.** No request accepts it. -- **An optional trailing `provider`** selects the payment account, as in the - payment interface. +- **An optional trailing `provider` selects the payment account.** The payment + interface uses the same argument. ## Documentation -| Document | Contents | -| -------------------------------------------------------------------- | --------------------------------------------------------------- | -| [Introduction](./docs/1.introduction.md) | Why a second interface, the portable core, who fires renewals | -| [Subscriptions](./docs/2.subscriptions.md) | Creating, periods, trials and anchors, statuses, cancelling | -| [Events](./docs/3.events.md) | The event set, delivery, and how it relates to payment webhooks | -| [Implementing](./docs/4.implementing.md) | Native and fallback implementations, the conformance suite | -| [Provider capability matrix](./docs/5.provider-capability-matrix.md) | Which providers fire renewals themselves, and what that means | +| Document | Contents | +| -------------------------------------------------------------------- | ------------------------------------------------------------- | +| [Introduction](./docs/1.introduction.md) | Why a second interface, who fires renewals, the portable core | +| [Subscriptions](./docs/2.subscriptions.md) | Creation, periods, trials and anchors, statuses, cancellation | +| [Events](./docs/3.events.md) | The event set, delivery, and the relation to payment webhooks | +| [Implementing](./docs/4.implementing.md) | Native and fallback implementations, the conformance suite | +| [Provider capability matrix](./docs/5.provider-capability-matrix.md) | Which providers fire renewals themselves, and what that means | ## Development -Use Node.js 24 and pnpm 10.6.5. Install the published payment interface from npm using the frozen lockfile: +Use the Node.js version in `.nvmrc` and the pnpm version in the `packageManager` +field of `package.json`. Install the published payment interface from npm with +the frozen lockfile: ```bash pnpm install --frozen-lockfile @@ -204,16 +213,18 @@ pnpm format:check pnpm knip ``` -CI runs the same checks against the published dependency without Git submodules or workspace overrides. +CI runs the same checks against the published dependency, without Git +submodules or workspace overrides. ## Conformance suite `src/tests/` ships in the package as `dist/tests` and runs against whichever -module implements the interface. It is much weaker than the payment suite, and -on purpose: a test cannot wait a month for a renewal, so for a native provider it -covers creation, reading, cancellation, method changes and request validation — -not renewals. The generic fallback's own suite drives an injected clock through -every renewal path and is the executable specification for the rest. +module implements the interface. It checks much less than the payment suite, on +purpose. A test cannot wait a month for a renewal. For a native provider, the +suite covers creation, reading, cancellation, method changes, and request +validation, but not renewals. The generic fallback has its own suite, which +drives an injected clock through every renewal path. That suite is the +executable specification for the rest. | Variable | Purpose | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | @@ -224,7 +235,10 @@ every renewal path and is the executable specification for the rest. ## Contributing -Read [CONTRIBUTING.md](./CONTRIBUTING.md) and run the [development checks](#development) before opening a pull request. Local contract tests do not require provider credentials; the provider conformance suite is a separate integration check. +Read [CONTRIBUTING.md](./CONTRIBUTING.md) and run the +[development checks](#development) before you open a pull request. The local +contract tests need no provider credentials. The provider conformance suite is a +separate integration check. ## License diff --git a/docs/1.introduction.md b/docs/1.introduction.md index 60d99a4..6045447 100644 --- a/docs/1.introduction.md +++ b/docs/1.introduction.md @@ -1,71 +1,78 @@ -# Introduction - -`@antelopejs/interface-subscriptions` defines provider-agnostic recurring -billing for AntelopeJS applications. A backend written against it bills a saved -payment method every period and learns what happened — paid, failed, ended — -without naming a provider, and without running a billing cycle of its own. +--- +title: Introduction +description: Why recurring billing is its own interface, who fires each renewal, and what the portable core covers. +mode: explanation +--- + +`@antelopejs/interface-subscriptions` defines recurring billing for AntelopeJS +applications that works with any payment provider. A backend written against it +bills a saved payment method every period. It learns whether each period was +paid, failed, or ended. It names no provider and runs no billing cycle of its +own. ## Why a second interface -Every call in `@antelopejs/interface-payment` is request and response against -the provider. A subscription is different in kind: it is a long-lived object -that must be acted on **when nobody is calling**. It needs two things the -payment contract never needed: +Every call in `@antelopejs/interface-payment` is a request to the provider and +its response. A subscription is a different kind of thing. It is a long-lived +object that something must act on **when nobody is calling**. It needs two +things the payment contract never needed: -1. **Durable state** — the subscription, its current period, its status. -2. **The passage of time** — something must fire at each renewal. +1. **Durable state.** The subscription, its current period, and its status. +2. **The passage of time.** Something must fire at each renewal. A provider with native subscriptions supplies both. A provider without them -leaves the merchant to fire each charge, and then something has to supply both -on the merchant's side. That something is a module with a database and a clock, -which is a subscriptions module's business and not a payment module's — so the -contract is its own interface, and: +leaves the merchant to fire each charge, so something on the merchant's side has +to supply both. That something is a module with a database and a clock, which +belongs in a subscriptions module and not in a payment module. For that reason +the contract is its own interface, with these results: -- a payment module whose provider cannot do subscriptions does not implement it, - rather than implementing it badly; -- the generic fallback, `@antelopejs/payment-subscriptions`, can depend on a - database and a scheduler without forcing either on every payment consumer; -- consumers that do not bill recurrently do not carry the surface. +- A payment module whose provider cannot do subscriptions does not implement the + interface. It does not implement it badly either. +- The generic fallback, `@antelopejs/payment-subscriptions`, can depend on a + database and a scheduler without forcing either on every payment consumer. +- Consumers that do not bill on a schedule do not carry the extra calls. ## Who fires the renewal -The question that sorts providers is not "is there a subscription object" but -"who fires each renewal charge". That is exactly what the fallback has to -supply. +The question that sorts providers is "who fires each renewal charge". Whether +the provider has a subscription object does not decide it. The fallback has to +supply exactly that renewal charge. | The provider fires renewals | The merchant fires them | | --------------------------- | ----------------------------- | | Stripe, PayPal, Mollie | Adyen, Checkout.com, Mangopay | -The split is even, so the fallback is half the providers rather than a rare edge. -[The capability matrix](./5.provider-capability-matrix.md) has the detail. +The split is even, so the fallback serves half the providers. It is not a rare +edge case. [The provider capability matrix](./5.provider-capability-matrix.md) +has the detail. ## The portable core -Expressible everywhere, including by the fallback: +Every implementation can express these, the fallback included: -- an amount, an interval and an interval count; -- a billing anchor (when the first paid period starts) or a trial end; -- a status, including `past_due`; -- cancelling now, or at the end of the period paid for; -- the current period's start and end. +- An amount, an interval, and an interval count. +- A billing anchor, which is when the first paid period starts, or a trial end. +- A status, including `past_due`. +- Cancellation now, or at the end of the period paid for. +- The start and the end of the current period. -Deliberately not modelled, because the fallback cannot honour them and a -contract only some implementations keep is a lie: proration, dunning schedules, -tax, coupons and discounts, metered billing, and more than one item per -subscription. A backend that needs one of these uses the provider's own -interface (`@antelopejs/interface-stripe`) alongside this one. +The contract leaves some features out on purpose. The fallback cannot honour +them, and a contract that only some implementations keep misleads its callers. +The features left out are proration, dunning schedules, tax, coupons and +discounts, metered billing, and more than one item per subscription. A backend +that needs one of these uses the provider's own interface, such as +`@antelopejs/interface-stripe`, alongside this one. ## Built on the payment interface A subscription charges a method saved through `@antelopejs/interface-payment`, -off-session. That pair — one on-session payment that saves a method, then any -number of off-session charges against it — is the payment interface's portable -recurring primitive, and this interface adds nothing underneath it. +off-session. The payment interface supports recurring charges portably with one +pair. One on-session payment saves a method, then any number of off-session +charges use it. This interface adds nothing underneath that pair. -Identifiers cross the two interfaces unchanged: a subscription's `customer`, -`paymentMethod` and `latestPayment` are payment-interface identifiers from the -same account, and `provider` selects that account in both. +Identifiers cross the two interfaces unchanged. The `customer`, `paymentMethod`, +and `latestPayment` of a subscription are payment-interface identifiers from the +same account. `provider` selects that account in both interfaces. ## Installation @@ -77,22 +84,23 @@ Wire a module that implements it alongside a payment module. ## Conventions -**Amounts are the payment interface's `Amount`**: integer minor units and an -uppercase ISO-4217 code. +**Amounts are the `Amount` of the payment interface.** An amount is an integer +of minor units and an uppercase ISO-4217 code. -**Times are Unix epoch milliseconds**, and periods are counted in UTC. +**Times are Unix epoch milliseconds.** Implementations count periods in UTC. -**Every mutating call takes an idempotency key**, positionally. Replaying it -returns the original outcome within the provider-defined replay window, -including rejections where the provider retains them. After expiration, -applications must reconcile uncertain payments before retrying; replay -protection is not indefinite. +**Every mutating call takes an idempotency key.** It is a positional argument. +Within the replay window that the provider defines, a replayed key returns the +original outcome. The outcome includes a rejection when the provider retains +it. After the window expires, reconcile uncertain payments before you retry. +Replay protection does not last forever. **A failed renewal is a status, not an exception.** The subscription goes -`past_due` and a `subscription.payment_failed` event is raised. Only a declined -_first_ period rejects, because then there is no subscription to put in a state. - -**`vendorData` is a read-only echo.** An implementation may surface what its -provider said; no request accepts provider-specific settings, because a -`Record` that compiles against every provider and works on one -hides exactly the coupling this interface exists to prevent. +`past_due`, and the implementation raises a `subscription.payment_failed` event. +Only a declined _first_ period rejects, because then no subscription exists to +put in a state. + +**`vendorData` is a read-only echo.** An implementation may expose what its +provider said. No request accepts settings for one provider. A +`Record` that compiles against every provider but works on only +one would hide the coupling that this interface exists to prevent. diff --git a/docs/2.subscriptions.md b/docs/2.subscriptions.md index 7eb0154..3e72327 100644 --- a/docs/2.subscriptions.md +++ b/docs/2.subscriptions.md @@ -1,23 +1,31 @@ -# Subscriptions +--- +title: Subscriptions +description: How a subscription is created, how its periods, trials and anchors work, what each status means, and how cancellation and errors behave. +mode: reference +--- ## Creating one -The payer is not present when a subscription charges, so the mandate is taken +The payer is not present when a subscription charges, so you take the mandate first, through the payment interface. Create an on-session payment with -`savePaymentMethod: true`, `customer` and `returnUrl`, and persist its ID. -If it returns `requires_action`, send the payer to `redirectUrl`. Do not list -saved methods or start the subscription while the payment is still pending or -requires action, or if it failed or was canceled. +`savePaymentMethod: true`, `customer`, and `returnUrl`, and persist its ID. If +it returns `requires_action`, send the payer to `redirectUrl`. Do not list saved +methods or start the subscription in these cases: -After a verified `payment.succeeded` or `payment.authorized` event, or a server -read with `GetPayment` reporting one of those states, use `ListPaymentMethods` -to find the saved method matching that payment's `paymentMethod`. Do not select -an unrelated method just because it appears first. The [README quick start](../README.md#quick-start) -shows the checkout and reconciliation steps separately. +- The payment is still `pending` or `requires_action`. +- The payment is `failed` or `canceled`. -`savePaymentMethod` cannot be combined with `offSession` — a mandate can only be -taken from a payer who is there. That first payment is an ordinary one-off; the -subscription starts after it, and its first period is a separate charge. +Wait for a verified `payment.succeeded` or `payment.authorized` event, or for a +server read with `GetPayment` that reports one of those states. Then use +`ListPaymentMethods` to find the saved method that matches the `paymentMethod` +of that payment. Do not select an unrelated method because it appears first. The +[README quick start](../README.md#quick-start) shows the checkout and the +reconciliation as separate steps. + +`savePaymentMethod` cannot be combined with `offSession`, because only a payer +who is present can give a mandate. That first payment is an ordinary one-off +payment. The subscription starts after it, and its first period is a separate +charge. ```ts import type { PaymentMethod } from "@antelopejs/interface-payment"; @@ -42,45 +50,47 @@ export async function subscribe( } ``` -Without a trial or a billing anchor, the first period is charged off-session -before `CreateSubscription` resolves: +Without a trial or a billing anchor, the implementation charges the first period +off-session before `CreateSubscription` resolves: | First charge | Result | | ------------------------ | --------------------------------------------------------------------------------------------- | | Succeeded | An `active` subscription | -| Still settling (a debit) | An `active` subscription; if the charge later fails it goes `past_due` | +| Still settling (a debit) | An `active` subscription. If the charge later fails, it goes `past_due`. | | Declined | Rejects with `SubscriptionPaymentError`, carrying the declined `Payment`. Nothing is created. | -Replaying the idempotency key within the provider-defined replay window returns -the saved outcome, including a rejection where retained by the provider. -After expiration, applications must reconcile uncertain payments and subscription -creation before retrying; the same key can create another charge. +Within the replay window that the provider defines, a replayed idempotency key +returns the saved outcome. The outcome includes a rejection when the provider +retains it. After the window expires, reconcile uncertain payments and +subscription creation before you retry, because the same key can create another +charge. ## Periods -A period is `intervalCount` × `interval`, where the interval is `day`, `week`, -`month` or `year`. Every implementation accepts periods up to a year; a longer -one may be refused with `InvalidSubscriptionRequestError`. +A period is `intervalCount` times `interval`. The interval is `day`, `week`, +`month`, or `year`. Every implementation accepts periods up to a year, which +`MAX_PORTABLE_PERIOD_DAYS` sets at 366 days. An implementation may refuse a +longer period with `InvalidSubscriptionRequestError`. -Periods are counted from the billing anchor in calendar units, in UTC, and never -from the end of the previous period. A monthly subscription anchored on 31 -January renews on 28 February (29 in a leap year), then on 31 March — not on the -28th of every month from then on. +Implementations count periods from the billing anchor in calendar units, in UTC, +and never from the end of the previous period. A monthly subscription anchored +on 31 January renews on 28 February, or 29 February in a leap year. It then +renews on 31 March, not on the 28th of every month from then on. `currentPeriodStart` and `currentPeriodEnd` describe the period most recently -billed for. The next charge is made at `currentPeriodEnd`. +billed for. The next charge happens at `currentPeriodEnd`. ## Trials and billing anchors Both defer the first charge, and they are mutually exclusive. -- **`trialEnd`**: the subscription is `trialing` until then, nothing is charged, - and the first paid period starts at the trial end. -- **`billingAnchor`**: the subscription is `active` until then — it exists and is - in good standing — but nothing is charged until the anchor, where the first paid - period starts. +- **`trialEnd`.** The subscription is `trialing` until then, and nothing is + charged. The first paid period starts at the trial end. +- **`billingAnchor`.** The subscription is `active` until then. It exists and is + in good standing, but nothing is charged until the anchor. The first paid + period starts at the anchor. -In both cases the period before the first charge runs from creation to that +In both cases, the period before the first charge runs from creation to that time, so `currentPeriodEnd` is the trial end or the anchor. ## Statuses @@ -92,23 +102,24 @@ time, so `currentPeriodEnd` is the trial end or the anchor. | `past_due` | Charging the current period failed. The implementation is retrying under its own policy. | | `canceled` | Ended. Terminal. `endedAt` and `endReason` (`requested` or `payment_failed`) say when and why. | -When a renewal fails, the period still advances: the new period is the current -one, and `past_due` says it is unpaid. When a retry succeeds the subscription is -`active` again in the same period; the cycle does not move. +When a renewal fails, the period still advances. The new period is the current +one, and `past_due` says that it is unpaid. When a retry succeeds, the +subscription is `active` again in the same period. The cycle does not move. ### `past_due` is portable to report, not to predict -What happens after a failure is the implementation's: a native provider follows -the retry schedule configured in its dashboard, and the generic fallback follows -its documented policy (three retries, one, three and seven days apart, then the -subscription ends with `endReason: "payment_failed"`). Two subscriptions in the -same state may therefore behave differently on different providers. Decide access -from the status; do not schedule anything on the assumption of a retry date. +The implementation decides what happens after a failure. A native provider +follows the retry schedule configured in its dashboard. The generic fallback +follows its documented policy. By default, it retries three times, one, three, +and seven days apart. When every retry has failed, the subscription ends with +`endReason: "payment_failed"`. Two subscriptions in the same state can therefore +behave differently on different providers. Decide access from the status. Do not +schedule anything on the assumption of a retry date. ## Recovering a `past_due` subscription -If the saved method will not work again — an expired card — take a new mandate -on-session and switch to it: +If the saved method will not work again, as with an expired card, take a new +mandate on-session and switch to it: ```ts import { @@ -129,7 +140,7 @@ export async function switchMethod( } ``` -Nothing is charged by the switch. The next scheduled attempt uses the new method. +The switch charges nothing. The next scheduled attempt uses the new method. ## Cancelling @@ -142,30 +153,32 @@ export async function cancel(id: string) { } ``` -Use distinct keys for distinct operations. Reuse a key only when retrying the -same request; reusing the period-end key for immediate cancellation replays the -first outcome instead of applying the change. +Use distinct keys for distinct operations. Reuse a key only to retry the same +request. If you reuse the period-end key for immediate cancellation, the call +replays the first outcome and does not apply the change. - `period_end` sets `cancelAtPeriodEnd`. The status does not change until the - period ends, at which point the subscription ends instead of renewing. Asking - again is not an error. -- `now` ends it at once. Nothing is refunded; refunds are `RefundPayment` on the - payment interface. A subscription set to end at period end can still be ended - now. -- Either on a subscription that has already ended raises + period ends. Then the subscription ends instead of renewing. Asking again is + not an error. +- `now` ends the subscription at once. Nothing is refunded. A refund is a + `RefundPayment` call on the payment interface. A subscription set to end at + period end can still be ended now. +- Either timing on a subscription that has already ended raises `InvalidSubscriptionStateError`, unless the idempotency key is a replay. ## Errors -| Error | When | -| --------------------------------- | ---------------------------------------------------------------------------- | -| `SubscriptionNotFoundError` | No subscription has that identifier under that `provider` | -| `InvalidSubscriptionRequestError` | A request outside the contract: a non-positive amount, a past trial end, ... | -| `InvalidSubscriptionStateError` | A change to a subscription that has ended | -| `SubscriptionPaymentError` | The first period was charged and declined; nothing was created | -| `SubscriptionError` | The base class; also the call could not be made at all | - -Each carries `providerCode` where the provider gave one. -`InvalidSubscriptionStateError.status` is an optional `SubscriptionStatus` -describing the actual state that blocked the operation. Its constructor is +| Error | When | +| --------------------------------- | --------------------------------------------------------------------------------- | +| `SubscriptionNotFoundError` | No subscription has that identifier under that `provider` | +| `InvalidSubscriptionRequestError` | A request outside the contract, such as a non-positive amount or a past trial end | +| `InvalidSubscriptionStateError` | A change to a subscription that has ended | +| `SubscriptionPaymentError` | The first period was charged and declined. Nothing was created. | +| `SubscriptionError` | The base class. Also raised when the call could not be made at all. | + +Each error carries `providerCode` when the provider gave one. +`InvalidSubscriptionStateError.status` is an optional `SubscriptionStatus` that +describes the actual state that blocked the operation. Its constructor is `new InvalidSubscriptionStateError(message, status?, providerCode?)`. +`SubscriptionNotFoundError` is also raised for an identifier that cannot be +parsed at all. diff --git a/docs/3.events.md b/docs/3.events.md index 95d4a6c..9cd7840 100644 --- a/docs/3.events.md +++ b/docs/3.events.md @@ -1,7 +1,11 @@ -# Events +--- +title: Events +description: The four subscription events, how they are delivered, and how they relate to payment webhooks. +mode: reference +--- A subscription changes while nobody is calling, so a backend learns of renewals, -failures and endings from events. +failures, and endings from events. ## The event set @@ -9,17 +13,18 @@ failures and endings from events. | -------------------------------- | ---------------------------------------------------------------------------- | | `subscription.created` | The subscription exists | | `subscription.payment_succeeded` | A period was paid for, including the first | -| `subscription.payment_failed` | Charging a period failed; the status is `past_due`, or the retries are spent | -| `subscription.canceled` | It ended, for any reason | +| `subscription.payment_failed` | Charging a period failed. The status is `past_due`, or the retries are spent | +| `subscription.canceled` | The subscription ended, for any reason | -An event carries the subscription as it was when the event happened, and, for -the two payment events, the payment-interface identifier of the charge in +An event carries the subscription as it was when the event happened. The two +payment events also carry the payment-interface identifier of the charge in `payment`. Read the charge with `GetPayment` for the amount or the decline reason. A `subscription.payment_failed` has no `payment` when the provider -refused to make the charge at all — the saved method had been removed, say. +refused to make the charge at all, for example because the saved method had +been removed. -Changing the payment method and scheduling a cancellation at period end raise no -event: the caller made those changes and already knows. +A change of payment method and a cancellation scheduled for period end raise no +event. The caller made those changes and already knows about them. ## Receiving them @@ -38,40 +43,42 @@ SubscriptionEvents.register("billing", async (event) => { }); ``` -`SubscriptionEvents` is a registering proxy, not a webhook route. Handlers are -registered under an identifier unique within your module, and unregistered -automatically when your module unloads. +`SubscriptionEvents` is a registering proxy, not a webhook route. You register +each handler under an identifier unique within your module. AntelopeJS +unregisters the handler when your module unloads. -Register handlers when your module starts. An event is delivered to the -handlers registered at the time; one raised while none are registered reaches -nobody and is not kept. +Register handlers when your module starts. The implementation delivers an event +to the handlers registered at that time. An event raised while no handler is +registered reaches nobody, and the implementation does not keep it. Delivery is **at least once**: -- A handler that throws or rejects has not handled the event, and it is delivered - again later — to every handler, so de-duplicate on `event.id`. -- A crash between a change and its delivery means the event is delivered when the - implementation next looks at that subscription. -- In a cluster, each event is delivered by one instance, to the handlers - registered in that instance. +- A handler that throws or rejects has not handled the event. The implementation + delivers the event again later, to every handler, so deduplicate on + `event.id`. +- After a crash between a change and its delivery, the implementation delivers + the event the next time it looks at that subscription. +- In a cluster, one instance delivers each event, to the handlers registered in + that instance. -Events may arrive late, and should not be read as more recent than state you -already hold. `GetSubscription` is always current. +Events can arrive late. Do not treat an event as more recent than state you +already hold. `GetSubscription` always returns the current state. ## How implementations learn of them -That is each implementation's business, and invisible to a consumer: +Each implementation decides how it learns of events, and a consumer cannot see +the difference: -- the generic fallback raises them from its own renewal clock, recording each in - its database with the change that caused it; -- a native provider raises them from the provider's webhooks. +- The generic fallback raises them from its own renewal clock. It records each + event in its database with the change that caused it. +- A native provider raises them from the provider's webhooks. ## Relation to payment events Every subscription charge is an ordinary payment-interface payment, so its -`payment.*` events reach your payment webhook route as well. They carry -`SUBSCRIPTION_METADATA_KEY` (`antelopeSubscription`) in their metadata, set to -the subscription's identifier: +`payment.*` events reach your payment webhook route as well. Their metadata +carries `SUBSCRIPTION_METADATA_KEY`, which is `antelopeSubscription`, set to the +identifier of the subscription: ```ts import { @@ -83,11 +90,11 @@ import { SUBSCRIPTION_METADATA_KEY } from "@antelopejs/interface-subscriptions"; export async function handlePaymentWebhook(request: WebhookRequest) { const event = await VerifyWebhook(request); if (event?.payment?.metadata?.[SUBSCRIPTION_METADATA_KEY]) { - return; // a subscription charge: handled by the subscription event + return; // The subscription event handles this charge. } } ``` -Handle a subscription charge in one place. The subscription event is the one -that says what the charge meant — which period it paid for, whether the -subscription is now `past_due`. +Handle a subscription charge in one place. The subscription event says what the +charge meant, such as which period it paid for and whether the subscription is +now `past_due`. diff --git a/docs/4.implementing.md b/docs/4.implementing.md index bfb45a7..b64e4f2 100644 --- a/docs/4.implementing.md +++ b/docs/4.implementing.md @@ -1,70 +1,85 @@ -# Implementing the interface +--- +title: Implementing the interface +description: Implement the subscriptions interface natively in a payment module, or wire the generic fallback, and run the conformance suite. +mode: how-to +--- -There are two kinds of implementation, and both are expected from the start. +An implementation is one of two kinds, and the interface expects both from the +start. ## Native: the provider fires renewals -A payment module whose provider has first-class subscriptions (Stripe, PayPal, -Mollie) implements this interface beside `@antelopejs/interface-payment`, in the -same module, mapping each call onto the provider's subscription object. It owns -no clock and no database. +A payment module whose provider has first-class subscriptions, such as Stripe, +PayPal, or Mollie, implements this interface beside +`@antelopejs/interface-payment`, in the same module. It maps each call onto the +subscription object of the provider. It owns no clock and no database. -What it must do that the provider will not do for it: +The module must do these things, which the provider does not do for it: - **Validate with `ValidateSubscriptionRequest`** before anything reaches the - provider, so every implementation enforces the portable request shape. Defer - relative date validation to a provider that checks its idempotency cache - first; a replay must not fail because its original date has passed. An - unknown customer or payment method is an `InvalidSubscriptionRequestError` - too, never a `SubscriptionNotFoundError`, which is reserved for the - subscription itself. -- **Report only its own subscriptions.** A provider account may hold - subscriptions made elsewhere — a hosted checkout, the dashboard. They were not - created through this interface, have no `reference`, and must not be listed, - read or raised as events. + provider, so every implementation enforces the portable request shape. If the + provider checks its idempotency cache first, leave relative date validation + to the provider. A replay must not fail because its original date has passed. + An unknown customer or payment method is also an + `InvalidSubscriptionRequestError`. It is never a `SubscriptionNotFoundError`, + which is reserved for the subscription itself. +- **Report only its own subscriptions.** A provider account can hold + subscriptions made elsewhere, such as in a hosted checkout or in the + dashboard. They were not created through this interface and have no + `reference`. Do not list them, read them, or raise events for them. - **Charge the first period at creation, or refuse.** A declined first charge - rejects with `SubscriptionPaymentError` and leaves nothing behind. On Stripe + rejects with `SubscriptionPaymentError` and leaves nothing behind. On Stripe, this is `payment_behavior: "error_if_incomplete"`. - **Count periods from the anchor**, in UTC calendar units. Providers that bill from an anchor already do. -- **Map the provider's statuses onto four.** Anything that means "a charge - failed and the provider is retrying" is `past_due`; anything that has ended or - will never charge again is `canceled`. Where the provider can hold a - subscription that will never charge again without ending it — Stripe's - `unpaid` — say so, and tell the merchant to configure it to cancel instead. +- **Map the statuses of the provider onto four.** A status that means "a charge + failed and the provider is retrying" is `past_due`. A status that has ended or + will never charge again is `canceled`. Some providers can hold a subscription + that will never charge again without ending it, such as Stripe's `unpaid`. + Document that case, and tell the merchant to configure the provider to cancel + instead. - **Raise the four events** from the provider's webhooks. A module that already - normalises webhooks for the payment interface can raise subscription events from - the same deliveries. + normalises webhooks for the payment interface can raise subscription events + from the same deliveries. - **Mark charges** with `SUBSCRIPTION_METADATA_KEY` when it normalises the underlying payments, so a consumer can tell them from one-off payments. -- **Echo, never accept, `vendorData`.** It is a `JsonObject`: put in it - strings, numbers, booleans, `null`, arrays and plain objects, not a `Date`, - a `Buffer` or a function. A value typed as an `interface` (a provider SDK - object, for instance) and a `readonly` array are rejected by the compiler: - build the object from a `type` alias or a literal, and spread a read-only - array into a mutable one. `NaN` and `Infinity` compile but serialize to - `null`. +- **Echo, never accept, `vendorData`.** It is a `JsonObject`. Put strings, + numbers, booleans, `null`, arrays, and plain objects in it, not a `Date`, a + `Buffer`, or a function. The compiler rejects a value typed as an `interface`, + such as a provider SDK object, and a `readonly` array. Build the object from a + `type` alias or a literal, and spread a read-only array into a mutable one. + `NaN` and `Infinity` compile, but `JSON.stringify` writes them as `null`. ## Fallback: the merchant fires renewals For every provider that leaves each charge to the merchant, wire `@antelopejs/payment-subscriptions`. It implements this interface on top of any -payment-interface provider, keeps every subscription in -`@antelopejs/interface-database`, and fires renewals from its own clock — the -Redis scheduler when one is wired, polling the database when not. +payment-interface provider and keeps every subscription in +`@antelopejs/interface-database`. It fires renewals from its own clock. That +clock is the Redis scheduler when one is wired. Otherwise it polls the +database. -A payment module should not implement this interface by embedding a clock of its -own: that would put a mandatory database and scheduler into a payment module. +Do not implement this interface in a payment module by embedding a clock in it. +That would make a database and a scheduler mandatory for the payment module. ## Wiring an implementation -The calls are declared directly as the public names, so the whole module is the -declaration: +Declare the calls under their public names, so the whole module is the +declaration. The `declare` lines below stand for your own implementations: -```ts fragment +```ts import * as subscriptions from "@antelopejs/interface-subscriptions"; +import type { SubscriptionEventHandler } from "@antelopejs/interface-subscriptions"; import { ImplementInterface } from "@antelopejs/interface-core"; +declare const CreateSubscription: typeof subscriptions.CreateSubscription; +declare const GetSubscription: typeof subscriptions.GetSubscription; +declare const ListSubscriptions: typeof subscriptions.ListSubscriptions; +declare const CancelSubscription: typeof subscriptions.CancelSubscription; +declare const UpdateSubscriptionPaymentMethod: typeof subscriptions.UpdateSubscriptionPaymentMethod; +declare function register(id: string, handler: SubscriptionEventHandler): void; +declare function unregister(id: string): void; + export function construct(): void { ImplementInterface(subscriptions, { CreateSubscription, @@ -77,19 +92,19 @@ export function construct(): void { } ``` -`SubscriptionEvents` is a registering proxy: its implementation is a `register` +`SubscriptionEvents` is a registering proxy. Its implementation is a `register` and `unregister` pair that keep the handlers your module delivers to. -List the interface in `antelopeJs.implements`, and the payment interface in +List the interface in `antelopeJs.implements`, and list the payment interface in `dependencies`. ## `ValidateSubscriptionRequest` `ValidateSubscriptionRequest(request: SubscriptionRequest, now?: number): void` checks the portable request rules synchronously. Supply `now` to also check -that trial and billing dates are in the future. It is Unix epoch -**milliseconds**, such as `Date.now()`, not seconds. Pass your injected clock -in tests or in an implementation that controls time. +that trial and billing dates are in the future. `now` is in Unix epoch +**milliseconds**, such as `Date.now()`, not seconds. Pass your injected clock in +tests or in an implementation that controls time. ```ts docs-check import { @@ -102,56 +117,59 @@ export function validateBeforeCreation(request: SubscriptionRequest): void { } ``` -The function returns `undefined` on success and throws -`InvalidSubscriptionRequestError` with a message naming the first failed rule: +The function returns `undefined` on success. Otherwise it throws +`InvalidSubscriptionRequestError` with a message that names the first failed +rule: -- `reference`, `customer` and `paymentMethod` must be non-empty. -- `amount.value` must be a positive integer of minor units; `amount.currency` +- `reference`, `customer`, and `paymentMethod` must be non-empty. +- `amount.value` must be a positive integer of minor units. `amount.currency` must contain exactly three uppercase letters. The helper checks the format, not membership in an ISO currency registry. -- `interval` must be `day`, `week`, `month` or `year`; an explicit +- `interval` must be `day`, `week`, `month`, or `year`. An explicit `intervalCount` must be a positive integer. -- `trialEnd` and `billingAnchor` are mutually exclusive. Each, if supplied, - must be finite. When `now` is supplied, each must also be strictly greater - than `now` (equality is invalid). - -An implementation may omit `now` only when its provider validates future dates -after resolving idempotent replays. Stripe uses this form: a retry with the -original key and parameters can return the saved subscription after its trial -or billing anchor has passed. A new request with a past date still fails at -the provider. Implementations that own their idempotency storage must resolve -replays before applying relative date validation themselves. - -This helper does not contact a provider, verify customer or saved-method -existence, or enforce provider-specific maximum periods. Implementations must -perform those checks separately and also report invalid requests with -`InvalidSubscriptionRequestError`. +- `trialEnd` and `billingAnchor` are mutually exclusive. Each one, if supplied, + must be finite. When `now` is supplied, each one must also be strictly + greater than `now`. Equality is invalid. + +Omit `now` only when your provider validates future dates after it resolves +idempotent replays. Stripe uses this form. A retry with the original key and +parameters can return the saved subscription after its trial or billing anchor +has passed. A new request with a past date still fails at the provider. If your +implementation owns its idempotency storage, resolve replays before you apply +relative date validation yourself. + +The helper does not contact a provider. It does not check that the customer or +the saved method exists, and it does not enforce the maximum periods of a +provider. Make those checks separately, and report invalid requests with +`InvalidSubscriptionRequestError` too. ## The conformance suite -`dist/tests/*.test.js` ships in the package. `ajs module test` runs it -automatically against any module that lists this interface in -`antelopeJs.implements`. - -It is weaker than the payment interface's suite, and the reason matters before -anyone reads a green run as a promise: **a test cannot wait a month.** Stripe has -test clocks, but Mollie and PayPal do not, so a portable suite cannot pin a -renewal, a trial ending, a failed retry or a period end. What it covers: - -- creating a subscription that charges at once, a trial, a billing anchor; -- the first charge's shape and its metadata marker; -- idempotent replay of creation and cancellation; -- reading back, listing newest first and per customer; -- cancelling now and at period end, and refusing to change one that has ended; -- switching the payment method; -- unknown identifiers and every invalid request; -- a declined first period, when the provider can supply a method that saves and - then declines; -- events, when they can reach the test process. - -Everything time-driven is pinned instead by the generic fallback's own suite, -which drives an injected clock. That suite is the executable specification a -native implementation's renewal behaviour is checked against by eye. +`dist/tests/*.test.js` ships in the package. `ajs module test` runs the suite +against any module that lists this interface in `antelopeJs.implements`. + +The suite checks less than the suite of the payment interface. Know why before +you read a green run as a promise: **a test cannot wait a month.** Stripe has +test clocks, but Mollie and PayPal do not. A portable suite therefore cannot pin +a renewal, a trial end, a failed retry, or a period end. The suite covers these +cases: + +- Creating a subscription that charges at once, one with a trial, and one with a + billing anchor. +- The shape of the first charge and its metadata marker. +- Idempotent replay of creation and cancellation. +- Reading back, and listing newest first and per customer. +- Cancelling now and at period end, and refusing to change a subscription that + has ended. +- Switching the payment method. +- Unknown identifiers and every invalid request. +- A declined first period, when the provider can supply a method that saves and + then declines. +- Events, when they can reach the test process. + +The generic fallback has its own suite, which drives an injected clock and pins +everything that depends on time. That suite is the executable specification. Check the +renewal behaviour of a native implementation against it by eye. | Variable | Purpose | | --------------------------------------------------- | --------------------------------------------------------------------------- | @@ -160,5 +178,5 @@ native implementation's renewal behaviour is checked against by eye. | `ANTELOPE_SUBSCRIPTIONS_TEST_EVENTS` | Set when the implementation's events reach the test process during the run. | | `ANTELOPE_PAYMENT_TEST_CURRENCY` | ISO-4217 code to run against. Defaults to `EUR`. | -With nothing implementing the interface, the calls queue and the suite times -out; it cannot report green by accident. +When nothing implements the interface, the calls queue and the suite times +out. It cannot report green by accident. diff --git a/docs/5.provider-capability-matrix.md b/docs/5.provider-capability-matrix.md index f3f4329..871547e 100644 --- a/docs/5.provider-capability-matrix.md +++ b/docs/5.provider-capability-matrix.md @@ -1,9 +1,13 @@ -# Provider capability matrix +--- +title: Provider capability matrix +description: Which payment providers fire renewals themselves, which implementation each one needs, and where native and fallback behaviour differ. +mode: reference +--- -Which providers fire renewals themselves decides which implementation a -deployment needs. Checked against each provider's documentation on 2026-09-18; -Stripe and PayPal were not re-checked then, both having first-class subscription -objects that bill on their own schedule. +Whether a provider fires renewals itself decides which implementation a +deployment needs. The matrix was checked against the documentation of each +provider on 2026-09-18. Stripe and PayPal were not checked again then, because +both have first-class subscription objects that bill on their own schedule. | Provider | Who fires renewals | Implementation | | ------------ | ------------------ | ------------------------------------------------------------- | @@ -18,35 +22,36 @@ objects that bill on their own schedule. for you at the interval you specified."_ - **Adyen**: subscriptions are _"recurring transactions you initiate at regular intervals"_, against a stored token and `shopperReference`. -- **Checkout.com**: no subscription or billing-plan resource at all. Recurring - is a stored instrument plus `payment_type: Recurring` and +- **Checkout.com**: no subscription or billing-plan resource at all. A + recurring charge is a stored instrument plus `payment_type: Recurring` and `merchant_initiated: true` on an ordinary payment. -- **Mangopay**: the Recurring PayIn Registration stores frequency, dates and - amounts, and handles the authentication chain between charges, but _"the - platform can initiate payments"_ — Mangopay does not fire them. The fallback - owns the clock and can record the schedule with Mangopay through its provider - schedule seam. - -Implemented: the generic fallback, and Stripe natively in `@antelopejs/stripe`, -which passes the conformance suite against live Stripe and is further tested on -Stripe test clocks — renewal, trial end, `past_due` and cancellation at period -end — which the portable suite cannot pin. PayPal and Mollie are plans. - -Times are kept at each provider's precision, which the contract only promises -to the second: Stripe stores seconds. Mollie's subscription `startDate` is a -calendar date, so on Mollie a trial end or billing anchor lands on a day rather -than an instant — a native Mollie implementation has to say so, or charge the -first period itself. Unverified against Mollie. +- **Mangopay**: the Recurring PayIn Registration stores frequency, dates, and + amounts, and handles the authentication chain between charges. But _"the + platform can initiate payments"_, so Mangopay does not fire them. The fallback + owns the clock. It can record the schedule with Mangopay through its provider + schedule interface (`ProviderSchedule`). + +Two implementations exist: the generic fallback, and Stripe natively in +`@antelopejs/stripe`. `@antelopejs/stripe` passes the conformance suite against +Stripe in test mode. Its tests on Stripe test clocks also cover what the portable +suite cannot pin: renewal, trial end, `past_due`, and cancellation at period end. +PayPal and Mollie are plans. + +Implementations keep times at the precision of each provider, and the contract +promises only the second. Stripe stores seconds. The subscription `startDate` of +Mollie is a calendar date, so on Mollie a trial end or a billing anchor lands on +a day, not an instant. A native Mollie implementation has to document that, or +charge the first period itself. This is not verified against Mollie. ## Where native and fallback differ, and the contract allows it -| Behaviour | Native | Fallback | -| ----------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------- | -| What happens after `past_due` | The provider's configured retry schedule | Three retries, one, three and seven days apart, then ends | -| When a renewal is charged | The provider's scheduler, near the time | At the time with Redis wired; within one poll otherwise | -| `vendorData` | What the provider said | Absent | -| Events | From the provider's webhooks | From its own clock, recorded with each change | -| An event's subscription | Stripe invoice events: read back at delivery, so a redelivery may show a later state | As it was when the event happened | +| Behaviour | Native | Fallback | +| ----------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| What happens after `past_due` | The provider's configured retry schedule | By default three retries, one, three, and seven days apart, then the subscription ends | +| When a renewal is charged | The provider's scheduler, near the time | At the time with Redis wired; within one poll otherwise | +| `vendorData` | What the provider said | The fallback's own renewal state: `reconciliationRequired`, `attemptStartedAt`, `attemptReplayUntil`, `phase` | +| Events | From the provider's webhooks | From its own clock, recorded with each change | +| An event's subscription | Stripe invoice events read it back at delivery, so a redelivery can show a later state | As it was when the event happened | ## Out of scope, and why @@ -54,8 +59,8 @@ first period itself. Unverified against Mollie. | ------------------------------ | ------------------------------------------------------------------------------------ | | Proration | Arithmetic every provider does differently, and the fallback would have to invent it | | Dunning schedules | Native providers configure retries in their dashboard; only the status is portable | -| Tax | A tax engine is a product of its own | +| Tax | A tax engine is a separate product | | Coupons and discounts | Provider objects with provider semantics | | Metered and usage billing | Needs usage reporting the fallback has no source for | | Several items per subscription | Needs proration to change any one of them | -| Write-through `vendorData` | Compiles everywhere, works on one provider: invisible coupling | +| Write-through `vendorData` | It compiles everywhere but works on one provider, which hides the coupling | diff --git a/package.json b/package.json index 19f16e9..5f2255d 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,9 @@ "test": "src/antelope.test.ts", "skills": [ "./skills" - ] + ], + "docs": { + "standard": true + } } }