Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 83 additions & 69 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
# @antelopejs/interface-subscriptions
# `@antelopejs/interface-subscriptions`

<div align="center">
<a href="https://www.npmjs.com/package/@antelopejs/interface-subscriptions"><img alt="npm" src="https://img.shields.io/npm/v/@antelopejs/interface-subscriptions?style=for-the-badge&labelColor=000000&color=000000"></a>
<a href="./LICENSE"><img alt="License" src="https://img.shields.io/npm/l/@antelopejs/interface-subscriptions?style=for-the-badge&labelColor=000000&color=000000"></a>
<a href="https://discord.gg/g4pFt6eXqu"><img alt="Discord" src="https://img.shields.io/badge/Discord-18181B?logo=discord&style=for-the-badge&color=000000"></a>
<a href="./docs/1.introduction.md"><img alt="Docs" src="https://img.shields.io/badge/Docs-18181B?style=for-the-badge&color=000000"></a>
<a href="https://www.npmjs.com/package/@antelopejs/interface-subscriptions"><img data-illustration alt="npm" src="https://img.shields.io/npm/v/@antelopejs/interface-subscriptions?style=for-the-badge&labelColor=000000&color=000000"></a>
<a href="./LICENSE"><img data-illustration alt="License" src="https://img.shields.io/npm/l/@antelopejs/interface-subscriptions?style=for-the-badge&labelColor=000000&color=000000"></a>
<a href="https://discord.gg/g4pFt6eXqu"><img data-illustration alt="Discord" src="https://img.shields.io/badge/Discord-18181B?logo=discord&style=for-the-badge&color=000000"></a>
<a href="./docs/1.introduction.md"><img data-illustration alt="Docs" src="https://img.shields.io/badge/Docs-18181B?style=for-the-badge&color=000000"></a>
</div>

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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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");

Expand All @@ -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 {
Expand All @@ -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

Expand All @@ -165,36 +170,40 @@ events by `event.id` as described in [Events](./docs/3.events.md).

<!-- antelopejs-docs-compat:end -->

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
Expand All @@ -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 |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
Expand All @@ -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

Expand Down
Loading
Loading