From 3d0b3a460b99041bd7dd19a465d3b69f1888f1ba Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 00:25:46 +0100 Subject: [PATCH 1/7] test: type the contract suite against local source (#12) * test: type the contract suite against local source The suite loads dist at run time but had no types for it, so it was typed only once the package was built. CI lints before any build, so the unbuilt copy typed every call as any. The module is now typed from src/index, and test/tsconfig.json gives the suite the node and mocha types. * test: resolve the contract suite's types from src through a package import The suite now requires #built, a package import whose types condition points at src/index.ts and whose default points at dist/index.js. TypeScript types the module from source with no dist on disk, and Node still runs the built file. This replaces the JSDoc type on the require, which left the value any until dist existed. --- package.json | 6 ++++++ test/contract.test.js | 2 +- test/tsconfig.json | 12 ++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 test/tsconfig.json diff --git a/package.json b/package.json index bfb6731..8f9d107 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,12 @@ ], "main": "dist/index.js", "types": "dist/index.d.ts", + "imports": { + "#built": { + "types": "./src/index.ts", + "default": "./dist/index.js" + } + }, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/test/contract.test.js b/test/contract.test.js index 4daacce..bf3f9c7 100644 --- a/test/contract.test.js +++ b/test/contract.test.js @@ -1,7 +1,7 @@ const assert = require("node:assert/strict"); const { RegisteringProxy } = require("@antelopejs/interface-core"); -const subscriptions = require("../dist/index.js"); +const subscriptions = require("#built"); /** * This package is a contract: the calls are `InterfaceFunction` declarations diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..d5528ec --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "allowJs": true, + "composite": false, + "declaration": false, + "noEmit": true, + "module": "nodenext", + "moduleResolution": "nodenext" + }, + "include": ["*.js"] +} From db88bdec99225ed4449c0d1727cf9d661d1aba78 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 01:56:09 +0100 Subject: [PATCH 2/7] build(deps): bump @antelopejs/tooling-configs to 0.2.17 (#11) * build(deps): bump @antelopejs/tooling-configs to 0.2.17 * refactor(tests): clear the type-aware findings the 0.2.17 rules report * chore: delete the emptied guardrails ratchet * chore: sync the guardrails pack after the ratchet deletion * Revert "chore: sync the guardrails pack after the ratchet deletion" This reverts commit ea96ad9f77c080afa49f1daecd5d07726decdbf2. * Revert "chore: delete the emptied guardrails ratchet" This reverts commit a845d7b1f52708fff392967608354c784d6685c2. * refactor(tests): keep the deliberately invalid interval as an assertion * chore: lower the ratchet to the one row that stays --- .github/workflows/ci.yml | 11 +++++++++-- AGENTS.md | 22 ++++++++++++++++------ README.md | 12 +++++++----- guardrails.ratchet.json | 1 + guardrails.suppressions.json | 3 ++- oxlint.config.ts | 6 +++++- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/tests/support.ts | 2 +- tests/json-object.types.ts | 28 ++++++++++++++-------------- 10 files changed, 61 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index affb25a..90ab75f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,9 @@ jobs: README.md docs docs-typescript-project: docs/tsconfig.json + renovate-sync: false permissions: - contents: write - actions: write + contents: read checks: if: always() @@ -29,3 +29,10 @@ jobs: steps: - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') run: exit 1 + renovate-sync: + if: >- + github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/') && github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: write + actions: write + uses: AntelopeJS/.github/.github/workflows/ci-renovate-sync.yml@v1 diff --git a/AGENTS.md b/AGENTS.md index 6469976..75b09f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,9 +44,9 @@ Hold every test you write or keep to this checklist: ### Cleanup proof -A behaviour-preserving cleanup PR shows `antelopejs-api-diff ` passing. Published declarations and built runtime exports stay unchanged; pass `--allow-added ` (repeatable, e.g. `'**/internal/**'`) to let added entries under that glob pass when their module is `@internal`, and copy its "Added (allowed)" list. A comment-only PR shows `antelopejs-comment-proof ` passing instead of a verifier. Both compare committed HEAD. +A behaviour-preserving cleanup PR shows `antelopejs-api-diff ` passing. Published declarations, built runtime exports and the names and signatures a DMS frontend module auto-imports stay unchanged; pass `--allow-added ` (repeatable, e.g. `'**/internal/**'`) to let added entries under that glob pass when their module is `@internal`, and copy its "Added (allowed)" list. Every export under a directory that `dms.frontend.build.ts` auto-imports is published, `@internal` or not; a helper that must stay private goes under `app/build/`. A comment-only PR shows `antelopejs-comment-proof ` passing instead of a verifier. Both compare committed HEAD. -Copy counts from the proof's base and HEAD table. Never type counts by hand. Keep the command and the committed head beside the copied output. The table counts a lint directive once per rule it names, inline directives included. +Copy the proof's whole output into the PR body, down to its closing `` line, which stamps the command, base, committed head and a digest. Never type counts by hand. List the lint findings a PR leaves in place by pasting the block `antelopejs-body-check --findings` prints and replacing each ``. `antelopejs-body-check ` fails an edited or stale proof block, proof output outside one, and a findings line that is not a live diagnostic at HEAD; counts in prose are not checked, so leave them out. The table counts a lint directive once per rule it names, inline directives included. Pass `--probe ` to api-diff when changing receivers, positional calls, or modules that no published entry reaches. The same captured script runs against both worktree roots, supplied as `argv[2]`, and prints JSON observations. Matching metadata alone does not prove those behaviours. @@ -58,11 +58,11 @@ Verify a boundary against the imports a developer or agent would plausibly write ### Suppression budget -`guardrails.suppressions.json` caps the suppressions in authored code: lint directives per rule they name (`*` for none), `@ts-expect-error`, `@ts-ignore` and `SAFETY:` comments. A suppression over its ceiling fails: fix the code, or remove another suppression of the same kind. A rule that keeps needing suppressions should be changed in the preset. Never raise or remove a number in that file: the check fails a ceiling higher than on the pull request base, and only the operator approves a raise. A directive disabling `anti-slop/require-safety-comment-for-type-assertion` is burn-down debt: remove the assertion, or, where it holds, replace the directive with `SAFETY: `, which takes over its room without a raise. After you remove suppressions, run `pnpm run suppressions:update` and commit the lowered file. +`guardrails.suppressions.json` caps the suppressions in authored code: lint directives per rule they name (`*` for none), `@ts-expect-error`, `@ts-ignore` and `SAFETY:` comments, and under `config` each rule a lint config (`oxlint.config.*`, `.oxlintrc.json`, `eslint.config.*`) switches off or to a warning, keyed by rule and `files` glob anchored at the config's directory, so widening or moving a scope is a new ceiling of 0, while narrowing one takes over the room it gives up. Switching a rule off in a config is a suppression too: fix the code its rule reports instead. A suppression over its ceiling fails: fix the code, or remove another suppression of the same kind. A rule that keeps needing suppressions should be changed in the preset. Never raise or remove a number in that file: the check fails a ceiling higher than on the pull request base, and only the operator approves a raise. A directive disabling `anti-slop/require-safety-comment-for-type-assertion` is burn-down debt: remove the assertion, or, where it holds, replace the directive with `SAFETY: `, which takes over its room without a raise. After you remove suppressions, run `pnpm run suppressions:update` and commit the lowered file. ### Debt -`guardrails.ratchet.json` records the lint findings and oversize files this repository already had. Its numbers only go down. After you remove debt, run `pnpm run ratchet:update` and commit the lowered file. A rule listed there is off for `oxlint`. The ratchet counts it instead, and the pre-commit hook rejects it on every line you add. +`guardrails.ratchet.json` records the lint findings and oversize files this repository already had. Its numbers only go down. After you remove debt, or after a pack upgrade, run `pnpm run ratchet:update` and commit the file: it lowers the numbers and adds only the rules the new pack release enabled, at today's count. Never edit the numbers by hand; CI fails one added or raised against the base. A rule listed there is off for `oxlint`. The ratchet counts it instead, and the pre-commit hook rejects it on every line you add. ### What enforces what @@ -76,24 +76,34 @@ Verify a boundary against the imports a developer or agent would plausibly write | Tests import the code under test from local source, not their own package name | `antelopejs/no-self-import-in-tests` | | No assertion that restates a constant | `antelopejs/no-constant-value-assertion` | | No test whose only check is "is a function" | `antelopejs/no-typeof-function-only-test` | +| String-keyed object tables are read through own keys only | `antelopejs/no-inherited-key-lookup` | +| A type guard checks each member it narrows to; keys with `Object.hasOwn` | `antelopejs/no-presence-only-type-guard` | +| A type guard checks the nested elements and values it claims | review; no tool | | Params ≤ 5, function ≤ 120 lines, file ≤ 500 lines, depth ≤ 4, complexity ≤ 20 | `eslint/max-*`, `eslint/complexity` | -| Cleanup preserves declarations and built runtime export metadata | `antelopejs-api-diff ` | +| Cleanup preserves declarations, runtime exports and DMS auto-imports | `antelopejs-api-diff ` | | Comment-only changes preserve JavaScript emit | `antelopejs-comment-proof ` | -| PR-body counts come from tracked code files | api-diff and comment-proof tables | +| PR-body proof tables are copied unedited from the current head | `antelopejs-body-check ` | +| PR-body findings-left lines are live diagnostics at HEAD | `antelopejs-body-check ` | | Receiver, positional and unpublished-module behaviour | `antelopejs-api-diff --probe ` | | Directive and `SAFETY:` reasons are true of the code | review; no tool | | Evasion-only and patched-internal findings go under Attention | review; no tool | | No-upward rules reject same-tier peers | review and import probes | | No `@ts-ignore` | `typescript/ban-ts-comment` | +| No once-used type parameter standing in for `unknown` | `typescript/no-unnecessary-type-parameters` | +| No `any` passed, assigned or returned where a type is declared | `typescript/no-unsafe-{argument,assignment,return}` | | Files ≤ 20,000 bytes, tests included | `antelopejs-check-size` (pre-commit) | | Suppressions stay within `guardrails.suppressions.json` | `antelopejs-suppressions` (pre-commit, checks) | +| No lint config switches off a rule to stay under the budget | `antelopejs-suppressions` (pre-commit, checks) | | No added directive disables the `SAFETY:`, comment or test rules | `antelopejs-suppressions` (pre-commit, checks) | | An added suppression gives a specific reason | `antelopejs-suppressions` (pre-commit, checks) | +| A directive in a file a change touches suppresses something and gives a reason | `antelopejs-suppressions` (pre-commit, checks) | | A block disable is closed in its file | `antelopejs-suppressions` (pre-commit, checks) | | Types check | `tsc` (pre-push) | | No dead files, exports, or dependencies | knip | | Managed files match the pack | `antelopejs-sync --check` | +| A CI gate reads `needs.` only of jobs in its `needs` | `antelopejs-sync --check` | | Entry-only rules exempt no tracked outsider | `antelopejs-sync --check` | +| A DMS layer's `build/backend-boundary.ts` is the pack's plugin | `antelopejs-sync --check` | | A test names the wrong implementation it catches | review; no tool | | Existing debt only shrinks | `antelopejs-ratchet` | diff --git a/README.md b/README.md index adbb737..5521ab4 100644 --- a/README.md +++ b/README.md @@ -151,12 +151,14 @@ events by `event.id` as described in [Events](./docs/3.events.md). ## Compatibility - + -| Package | Range | -| ------------------------------- | ---------------- | -| `@antelopejs/interface-core` | `>=0.0.5 <1.0.0` | -| `@antelopejs/interface-payment` | `>=0.0.2 <1.0.0` | +| Package | Supported | Tested with | +| ------------------------------- | ---------------- | ----------- | +| `@antelopejs/interface-core` | `>=0.0.5 <1.0.0` | `0.1.1` | +| `@antelopejs/interface-payment` | `>=0.0.2 <1.0.0` | `0.0.2` | + + These are the peer requirements in `package.json`. Development uses the same payment-interface semver range. Check your implementing module's requirements diff --git a/guardrails.ratchet.json b/guardrails.ratchet.json index 8286b65..e22d714 100644 --- a/guardrails.ratchet.json +++ b/guardrails.ratchet.json @@ -1,4 +1,5 @@ { + "pack": "0.2.17", "rules": { "anti-slop/require-safety-comment-for-type-assertion": 1 }, diff --git a/guardrails.suppressions.json b/guardrails.suppressions.json index 6936c18..76c39b1 100644 --- a/guardrails.suppressions.json +++ b/guardrails.suppressions.json @@ -2,5 +2,6 @@ "rules": {}, "tsExpectError": 0, "tsIgnore": 0, - "safety": 0 + "safety": 0, + "config": {} } diff --git a/oxlint.config.ts b/oxlint.config.ts index fbd8c24..b654d03 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,7 +1,11 @@ import { defineConfig } from "oxlint"; -import { antelopePreset } from "@antelopejs/tooling-configs/oxc/lint"; +import { + ANTELOPE_IGNORE_PATTERNS, + antelopePreset, +} from "@antelopejs/tooling-configs/oxc/lint"; export default defineConfig({ extends: [antelopePreset()], + ignorePatterns: ANTELOPE_IGNORE_PATTERNS, options: { typeAware: true }, }); diff --git a/package.json b/package.json index 8f9d107..e594644 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "@antelopejs/core": "1.16.0", "@antelopejs/interface-core": ">=0.0.5 <1.0.0", "@antelopejs/interface-payment": ">=0.0.2 <1.0.0", - "@antelopejs/tooling-configs": "^0.2.11", + "@antelopejs/tooling-configs": "^0.2.17", "@types/mocha": "^10.0.10", "@types/node": "^22.19.15", "eslint-plugin-perfectionist": "^5.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e28c00..a87b292 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: '>=0.0.2 <1.0.0' version: 0.0.2(@antelopejs/interface-core@0.1.1) '@antelopejs/tooling-configs': - specifier: ^0.2.11 - version: 0.2.11(eslint-plugin-perfectionist@5.12.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(knip@6.39.0)(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2003)) + specifier: ^0.2.17 + version: 0.2.17(eslint-plugin-perfectionist@5.12.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(knip@6.39.0)(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2003)) '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -75,8 +75,8 @@ packages: peerDependencies: '@antelopejs/interface-core': '>=0.0.3 <1.0.0' - '@antelopejs/tooling-configs@0.2.11': - resolution: {integrity: sha512-11reej4rSQuPEQqQgef60SyQZ8XGWlUSshD4RXLXPBpyFGVQmOqagKmj1zTg8QAktzwxxokhk/8JrIyChIv3LA==} + '@antelopejs/tooling-configs@0.2.17': + resolution: {integrity: sha512-8fwh7xQPzazP/l/ivEwRKpfN8qShV9+PRizpI9cBewe2TwoRPu4n95w6sYSlygiRmm0HxLJYZFhmSPRgtiNo1Q==} engines: {node: '>=22.18.0'} hasBin: true peerDependencies: @@ -2511,7 +2511,7 @@ snapshots: dependencies: '@antelopejs/interface-core': 0.1.1 - '@antelopejs/tooling-configs@0.2.11(eslint-plugin-perfectionist@5.12.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(knip@6.39.0)(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2003))': + '@antelopejs/tooling-configs@0.2.17(eslint-plugin-perfectionist@5.12.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(knip@6.39.0)(oxfmt@0.66.0)(oxlint@1.81.0(oxlint-tsgolint@7.0.2003))': dependencies: '@oxlint/plugins': 1.81.0 dts-bundle-generator: 9.5.1 diff --git a/src/tests/support.ts b/src/tests/support.ts index a72b439..b97fbaa 100644 --- a/src/tests/support.ts +++ b/src/tests/support.ts @@ -270,7 +270,7 @@ export async function rejectsWith( ): Promise { const error: unknown = await work.then( () => undefined, - (reason) => reason, + (reason: unknown) => reason, ); assert.ok( error instanceof expected, diff --git a/tests/json-object.types.ts b/tests/json-object.types.ts index 28f1b32..e8beefd 100644 --- a/tests/json-object.types.ts +++ b/tests/json-object.types.ts @@ -3,21 +3,21 @@ import type { JsonObject, Subscription } from "../src/index"; type Accepts = [Value] extends [Target] ? true : false; type Not = Flag extends true ? false : true; +type Proof = Flag; + interface InterfaceTyped { name: string; } -declare function assertType(): Flag; - -assertType>(); -assertType>(); -assertType< - Accepts ->(); -assertType, { phase: null }>>(); -assertType>>(); -assertType>>(); -assertType string }>>>(); -assertType>>(); -assertType>>(); -assertType>>(); +export type Proofs = [ + Proof>, + Proof>, + Proof>, + Proof, { phase: null }>>, + Proof>>, + Proof>>, + Proof string }>>>, + Proof>>, + Proof>>, + Proof>>, +]; From 9eebc708114931daf7531d12b7b48f4b93060001 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 06:29:10 +0100 Subject: [PATCH 3/7] docs: typecheck every example and drop hand-written version prose (#13) --- .github/workflows/ci.yml | 2 +- README.md | 11 ++++++-- docs/2.subscriptions.md | 60 ++++++++++++++++++++++++++++------------ docs/3.events.md | 22 ++++++++++++--- docs/4.implementing.md | 2 +- docs/tsconfig.json | 2 ++ 6 files changed, 72 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90ab75f..9ee1268 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ permissions: jobs: ci: - uses: AntelopeJS/.github/.github/workflows/ci-node.yml@v1 + uses: AntelopeJS/.github/.github/workflows/ci-node.yml@v1-campaign with: docs-files: | README.md diff --git a/README.md b/README.md index 5521ab4..84dd034 100644 --- a/README.md +++ b/README.md @@ -139,14 +139,19 @@ off-session before `CreateSubscription` resolves. Adapt those amounts to your billing policy. Register an idempotent handler for subsequent lifecycle events: ```ts -import { SubscriptionEvents } from "@antelopejs/interface-subscriptions"; +import { + SubscriptionEvents, + type Subscription, +} from "@antelopejs/interface-subscriptions"; + +declare function grantOrRevokeAccess(subscription: Subscription): Promise; SubscriptionEvents.register("billing", async (event) => { await grantOrRevokeAccess(event.subscription); }); ``` -`grantOrRevokeAccess` stands for your application's access policy. Deduplicate +`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). ## Compatibility @@ -162,7 +167,7 @@ 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` 1.16.0 is a development test runner, not a runtime peer. +too. `@antelopejs/core` is a development test runner, not a runtime peer. ## Conventions diff --git a/docs/2.subscriptions.md b/docs/2.subscriptions.md index 723beb8..7eb0154 100644 --- a/docs/2.subscriptions.md +++ b/docs/2.subscriptions.md @@ -20,17 +20,26 @@ 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. ```ts -const subscription = await CreateSubscription( - { - reference: accountId, - customer, - paymentMethod: method.id, - amount: { value: 1900, currency: "EUR" }, - interval: "month", - intervalCount: 1, - }, - `subscribe-${accountId}`, -); +import type { PaymentMethod } from "@antelopejs/interface-payment"; +import { CreateSubscription } from "@antelopejs/interface-subscriptions"; + +export async function subscribe( + accountId: string, + customer: string, + method: PaymentMethod, +) { + return await CreateSubscription( + { + reference: accountId, + customer, + paymentMethod: method.id, + amount: { value: 1900, currency: "EUR" }, + interval: "month", + intervalCount: 1, + }, + `subscribe-${accountId}`, + ); +} ``` Without a trial or a billing anchor, the first period is charged off-session @@ -102,11 +111,22 @@ If the saved method will not work again — an expired card — take a new manda on-session and switch to it: ```ts -await UpdateSubscriptionPaymentMethod( - subscription.id, - newMethodId, - idempotencyKey, -); +import { + UpdateSubscriptionPaymentMethod, + type Subscription, +} from "@antelopejs/interface-subscriptions"; + +export async function switchMethod( + subscription: Subscription, + newMethodId: string, + idempotencyKey: string, +) { + await UpdateSubscriptionPaymentMethod( + subscription.id, + newMethodId, + idempotencyKey, + ); +} ``` Nothing is charged by the switch. The next scheduled attempt uses the new method. @@ -114,8 +134,12 @@ Nothing is charged by the switch. The next scheduled attempt uses the new method ## Cancelling ```ts -await CancelSubscription(id, `cancel-period-end-${id}`, "period_end"); // run out the paid period -await CancelSubscription(id, `cancel-now-${id}`, "now"); // end immediately +import { CancelSubscription } from "@antelopejs/interface-subscriptions"; + +export async function cancel(id: string) { + await CancelSubscription(id, `cancel-period-end-${id}`, "period_end"); // run out the paid period + await CancelSubscription(id, `cancel-now-${id}`, "now"); // end immediately +} ``` Use distinct keys for distinct operations. Reuse a key only when retrying the diff --git a/docs/3.events.md b/docs/3.events.md index f046c4c..95d4a6c 100644 --- a/docs/3.events.md +++ b/docs/3.events.md @@ -24,7 +24,13 @@ event: the caller made those changes and already knows. ## Receiving them ```ts -import { SubscriptionEvents } from "@antelopejs/interface-subscriptions"; +import { + SubscriptionEvents, + type SubscriptionEvent, +} from "@antelopejs/interface-subscriptions"; + +declare function alreadyHandled(eventId: string): Promise; +declare function applyToAccount(event: SubscriptionEvent): Promise; SubscriptionEvents.register("billing", async (event) => { if (await alreadyHandled(event.id)) return; @@ -68,9 +74,17 @@ Every subscription charge is an ordinary payment-interface payment, so its the subscription's identifier: ```ts -const event = await VerifyWebhook(request); -if (event?.payment?.metadata?.[SUBSCRIPTION_METADATA_KEY]) { - return; // a subscription charge: handled by the subscription event +import { + VerifyWebhook, + type WebhookRequest, +} from "@antelopejs/interface-payment"; +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 + } } ``` diff --git a/docs/4.implementing.md b/docs/4.implementing.md index afa2d44..bfb45a7 100644 --- a/docs/4.implementing.md +++ b/docs/4.implementing.md @@ -61,7 +61,7 @@ own: that would put a mandatory database and scheduler into a payment module. The calls are declared directly as the public names, so the whole module is the declaration: -```ts +```ts fragment import * as subscriptions from "@antelopejs/interface-subscriptions"; import { ImplementInterface } from "@antelopejs/interface-core"; diff --git a/docs/tsconfig.json b/docs/tsconfig.json index c3e8775..fff0882 100644 --- a/docs/tsconfig.json +++ b/docs/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../tsconfig.json", "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", "noEmit": true, "composite": false, "incremental": false, From fa3d01347839a81f7d14d4c0661da62c547473fc Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 12:26:45 +0100 Subject: [PATCH 4/7] test: assert the invalid interval rejection with ts-expect-error instead of a cast (#14) * test: assert the invalid interval rejection with ts-expect-error instead of a cast The conformance case for an interval outside the vocabulary now passes the invalid value directly, with a @ts-expect-error that names the rejection under test, and a Partial return type so the error stays on that one line. The ratchet has no debt left, so its file is deleted; the suppressions budget raises tsExpectError from 0 to 1 for this one directive. * chore: sync the guardrails pack after the ratchet is deleted --- AGENTS.md | 7 +------ guardrails.ratchet.json | 7 ------- guardrails.suppressions.json | 2 +- lefthook.yml | 3 --- package.json | 2 -- src/tests/subscriptions-changes.test.ts | 5 +++-- 6 files changed, 5 insertions(+), 21 deletions(-) delete mode 100644 guardrails.ratchet.json diff --git a/AGENTS.md b/AGENTS.md index 75b09f5..992f372 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ The AntelopeJS interface for recurring billing of a saved payment method, publis ## Guardrails -Finish every change with `pnpm run checks`. It runs typecheck, lint, format, knip, tests, size, ratchet, suppressions, and sync, and prints only what failed. The change is done when it prints no failure. +Finish every change with `pnpm run checks`. It runs typecheck, lint, format, knip, tests, size, suppressions, and sync, and prints only what failed. The change is done when it prints no failure. The tools enforce most rules, listed in the table at the end. The sections below hold the rules no tool checks. @@ -60,10 +60,6 @@ Verify a boundary against the imports a developer or agent would plausibly write `guardrails.suppressions.json` caps the suppressions in authored code: lint directives per rule they name (`*` for none), `@ts-expect-error`, `@ts-ignore` and `SAFETY:` comments, and under `config` each rule a lint config (`oxlint.config.*`, `.oxlintrc.json`, `eslint.config.*`) switches off or to a warning, keyed by rule and `files` glob anchored at the config's directory, so widening or moving a scope is a new ceiling of 0, while narrowing one takes over the room it gives up. Switching a rule off in a config is a suppression too: fix the code its rule reports instead. A suppression over its ceiling fails: fix the code, or remove another suppression of the same kind. A rule that keeps needing suppressions should be changed in the preset. Never raise or remove a number in that file: the check fails a ceiling higher than on the pull request base, and only the operator approves a raise. A directive disabling `anti-slop/require-safety-comment-for-type-assertion` is burn-down debt: remove the assertion, or, where it holds, replace the directive with `SAFETY: `, which takes over its room without a raise. After you remove suppressions, run `pnpm run suppressions:update` and commit the lowered file. -### Debt - -`guardrails.ratchet.json` records the lint findings and oversize files this repository already had. Its numbers only go down. After you remove debt, or after a pack upgrade, run `pnpm run ratchet:update` and commit the file: it lowers the numbers and adds only the rules the new pack release enabled, at today's count. Never edit the numbers by hand; CI fails one added or raised against the base. A rule listed there is off for `oxlint`. The ratchet counts it instead, and the pre-commit hook rejects it on every line you add. - ### What enforces what | Rule | Enforced by | @@ -105,6 +101,5 @@ Verify a boundary against the imports a developer or agent would plausibly write | Entry-only rules exempt no tracked outsider | `antelopejs-sync --check` | | A DMS layer's `build/backend-boundary.ts` is the pack's plugin | `antelopejs-sync --check` | | A test names the wrong implementation it catches | review; no tool | -| Existing debt only shrinks | `antelopejs-ratchet` | diff --git a/guardrails.ratchet.json b/guardrails.ratchet.json deleted file mode 100644 index e22d714..0000000 --- a/guardrails.ratchet.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "pack": "0.2.17", - "rules": { - "anti-slop/require-safety-comment-for-type-assertion": 1 - }, - "oversizeFiles": {} -} diff --git a/guardrails.suppressions.json b/guardrails.suppressions.json index 76c39b1..4618231 100644 --- a/guardrails.suppressions.json +++ b/guardrails.suppressions.json @@ -1,6 +1,6 @@ { "rules": {}, - "tsExpectError": 0, + "tsExpectError": 1, "tsIgnore": 0, "safety": 0, "config": {} diff --git a/lefthook.yml b/lefthook.yml index ff792b5..779c267 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -8,9 +8,6 @@ pre-commit: run: pnpm exec oxlint --no-error-on-unmatched-pattern {staged_files} antelopejs-size: run: pnpm exec antelopejs-check-size --staged {staged_files} - antelopejs-ratchet: - glob: "*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue}" - run: pnpm exec antelopejs-ratchet --staged {staged_files} antelopejs-suppressions: glob: "*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue}" run: pnpm exec antelopejs-suppressions --staged diff --git a/package.json b/package.json index e594644..19f16e9 100644 --- a/package.json +++ b/package.json @@ -59,9 +59,7 @@ "prepare": "if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && command -v lefthook >/dev/null 2>&1; then lefthook install; fi", "checks": "antelopejs-checks", "size": "antelopejs-check-size", - "ratchet": "antelopejs-ratchet", "sync": "antelopejs-sync", - "ratchet:update": "antelopejs-ratchet --update", "sync:check": "antelopejs-sync --check", "typecheck": "tsc --noEmit", "suppressions": "antelopejs-suppressions", diff --git a/src/tests/subscriptions-changes.test.ts b/src/tests/subscriptions-changes.test.ts index 7c65326..4d5a834 100644 --- a/src/tests/subscriptions-changes.test.ts +++ b/src/tests/subscriptions-changes.test.ts @@ -221,8 +221,9 @@ describe(`requests outside the contract (${CHARGEABLE})`, function () { "a fractional amount": () => ({ amount: amount(10.5) }), "an interval count of zero": () => ({ intervalCount: 0 }), "a fractional interval count": () => ({ intervalCount: 1.5 }), - "an interval outside the vocabulary": () => ({ - interval: "fortnight" as SubscriptionRequest["interval"], + "an interval outside the vocabulary": (): Partial => ({ + // @ts-expect-error "fortnight" is outside the interval vocabulary, which CreateSubscription must refuse with InvalidSubscriptionRequestError + interval: "fortnight", }), "a trial that has already ended": (now) => ({ trialEnd: now - DAY_MS }), "a billing anchor in the past": (now) => ({ From b6645bdd0feba722d681067fe445df342cd7fda6 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sun, 1 Nov 2026 00:44:12 +0100 Subject: [PATCH 5/7] feat(skills): add the verify-interface verification skill (#16) * feat(skills): add the verify-interface verification skill * fix(skills): cover the error classes and load the conformance helpers in the verify-interface skill * fix(skills): seed breaks that build and a format door that corrupts every vocabulary --- .agents/skills/verify-interface/SKILL.md | 137 +++++++++++ .../verify-interface/features/README.md | 18 ++ .../skills/verify-interface/features/calls.md | 33 +++ .../verify-interface/features/conformance.md | 35 +++ .../verify-interface/features/errors.md | 30 +++ .../verify-interface/features/field-types.md | 31 +++ .../verify-interface/features/signatures.md | 31 +++ .../skills/verify-interface/features/smoke.md | 30 +++ .../verify-interface/features/statuses.md | 31 +++ .../features/validation-dates.md | 31 +++ .../features/validation-fields.md | 31 +++ .../features/validation-interval.md | 31 +++ .../features/validation-order.md | 30 +++ .../verify-interface/features/vocabularies.md | 31 +++ .../skills/verify-interface/journey-kit.ts | 126 +++++++++++ .agents/skills/verify-interface/package.json | 4 + .agents/skills/verify-interface/probes.ts | 52 +++++ .../seeded-breaks/calls.patch | 33 +++ .../seeded-breaks/conformance.patch | 13 ++ .../seeded-breaks/errors.patch | 13 ++ .../seeded-breaks/field-types.patch | 13 ++ .../seeded-breaks/signatures.patch | 13 ++ .../seeded-breaks/smoke.patch | 18 ++ .../seeded-breaks/statuses.patch | 13 ++ .../seeded-breaks/validation-dates.patch | 13 ++ .../seeded-breaks/validation-fields.patch | 13 ++ .../seeded-breaks/validation-interval.patch | 13 ++ .../seeded-breaks/validation-order.patch | 15 ++ .../seeded-breaks/vocabularies.patch | 13 ++ .../seeded-doors/contract.patch | 9 + .../seeded-doors/format.patch | 19 ++ .../seeded-doors/publish.patch | 13 ++ .../skills/verify-interface/suite-scripts.ts | 33 +++ .agents/skills/verify-interface/tsconfig.json | 14 ++ .../verify-interface/validation-cases.ts | 170 ++++++++++++++ .../skills/verify-interface/verify.config.ts | 214 ++++++++++++++++++ .../verify-interface/vocabulary-cases.ts | 105 +++++++++ .claude/skills/verify-interface | 1 + AGENTS.md | 2 + 39 files changed, 1475 insertions(+) create mode 100644 .agents/skills/verify-interface/SKILL.md create mode 100644 .agents/skills/verify-interface/features/README.md create mode 100644 .agents/skills/verify-interface/features/calls.md create mode 100644 .agents/skills/verify-interface/features/conformance.md create mode 100644 .agents/skills/verify-interface/features/errors.md create mode 100644 .agents/skills/verify-interface/features/field-types.md create mode 100644 .agents/skills/verify-interface/features/signatures.md create mode 100644 .agents/skills/verify-interface/features/smoke.md create mode 100644 .agents/skills/verify-interface/features/statuses.md create mode 100644 .agents/skills/verify-interface/features/validation-dates.md create mode 100644 .agents/skills/verify-interface/features/validation-fields.md create mode 100644 .agents/skills/verify-interface/features/validation-interval.md create mode 100644 .agents/skills/verify-interface/features/validation-order.md create mode 100644 .agents/skills/verify-interface/features/vocabularies.md create mode 100644 .agents/skills/verify-interface/journey-kit.ts create mode 100644 .agents/skills/verify-interface/package.json create mode 100644 .agents/skills/verify-interface/probes.ts create mode 100644 .agents/skills/verify-interface/seeded-breaks/calls.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/conformance.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/errors.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/field-types.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/signatures.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/smoke.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/statuses.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/validation-dates.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/validation-fields.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/validation-interval.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/validation-order.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/vocabularies.patch create mode 100644 .agents/skills/verify-interface/seeded-doors/contract.patch create mode 100644 .agents/skills/verify-interface/seeded-doors/format.patch create mode 100644 .agents/skills/verify-interface/seeded-doors/publish.patch create mode 100644 .agents/skills/verify-interface/suite-scripts.ts create mode 100644 .agents/skills/verify-interface/tsconfig.json create mode 100644 .agents/skills/verify-interface/validation-cases.ts create mode 100644 .agents/skills/verify-interface/verify.config.ts create mode 100644 .agents/skills/verify-interface/vocabulary-cases.ts create mode 120000 .claude/skills/verify-interface diff --git a/.agents/skills/verify-interface/SKILL.md b/.agents/skills/verify-interface/SKILL.md new file mode 100644 index 0000000..70db01a --- /dev/null +++ b/.agents/skills/verify-interface/SKILL.md @@ -0,0 +1,137 @@ +--- +name: verify-interface +description: "Drive this AntelopeJS interface the way a module author does: pack it, load it in `ajs project dev` next to a stub provider wired through ImplementInterface and a consumer, call every declared function through core, and check the minor-bump rule against the published version. Use to prove a change to the interface works before merge, to verify a PR, or to check that a seeded break is caught." +--- + +# Verify interface + +The user of an interface is a module author: one module implements it, others import it and call its functions through core. Every deterministic step is an `antelopejs-verify` command: you choose which journeys to run and judge what they show. Never replace a command with a shell recipe. When a journey you need does not exist, add it to `verify.config.ts`, name it in its feature file, then run it. + +`antelopejs-sync` writes this file, `package.json` and `tsconfig.json` here, and keeps them in sync. Everything else in `.agents/skills/verify-interface/` is the repository's own layer, which sync never touches. + +## Launch + +```bash +pnpm exec antelopejs-verify new --base origin/main --title "" +``` + +`new` prints the run folder; pass it as `` below. `new` runs the setup (`pnpm run build` by default), then packs the interface with `pnpm pack` and extracts it, so journeys load what npm would publish, `files` and `exports` included. Every `services` entry of `verify.config.ts` then starts in its own Docker container on a free loopback port, as for a module. Every journey gets its own `HOME` and scratch folder, `CI=1`, `AJS_NO_UPDATE_CHECK=1`, `NO_COLOR=1` and a free port from the kernel, so two runs never share an instance. Run it from the repository root after installing dependencies. + +## Doctor + +```bash +pnpm exec antelopejs-verify doctor +``` + +Doctor checks that HEAD has not moved since `new`, `ajs` comes from the repository's `@antelopejs/core`, the build is newer than `src/`, what `new` prepared is there, and no process the run recorded is still alive. Do not drive a run that fails doctor. + +## Drive + +```bash +pnpm exec antelopejs-verify plan --base origin/main +pnpm exec antelopejs-verify journey [journey...] +``` + +`plan` maps the diff to features and lists unmapped surfaces. `journey` runs a feature's journeys, or the ones named. The pack ships these journeys for this kind; a feature file names them next to the repository's own: + +- `every-function`: a fixture project loads the packed interface with a stub provider and a consumer module. The consumer walks the loaded interface, and every function the `.d.ts` files declare is called through core: + - an async proxy answers the stub's echo, and the stub's call log shows the same arguments (the second view); + - a registering proxy registers and unregisters, and the stub logs both; + - an event proxy delivers the emitted arguments to a registered handler; + - a plain function runs the `interface.calls` hook of `verify.config.ts` and must return its `expect`; one only providers call is listed in `interface.providerSide` with why. + A declared function with none of these fails the journey. +- `minor-bump`: reads the declared functions of the packed head and of the published version (`npm pack @`). An added, changed or removed function needs a release of at least the next minor. The release is the head version when it is already bumped, otherwise what changelogen makes of the change title (`new --title`) or the commit subjects since the base. + +## Evidence + +Each step writes `/evidence////` with `command.txt`, `stdout.txt`, `stderr.txt`, `exit-code.txt`, `pid.txt` for background processes, `tree.txt`, `http.json` for every request, and `checks.json`. `result.json` holds a journey's result and `feature.json` the merged results of the feature. Read the evidence before you judge a PASS: a check proves only what it asserts. + +## Cleanup + +```bash +pnpm exec antelopejs-verify clean +``` + +`clean` stops what the run started, by recorded PID and container name, and removes everything but `evidence/` and `run.json`. Run it after every run, failed ones included. + +## The repository's own layer + +- `verify.config.ts` default-exports a `VerifyConfig` from `@antelopejs/tooling-configs/verify`: + - `doors`: one entry per door kind (`data`, `format`, `contract`, `outbound`, `exposure`, `publish`), each `null` when it cannot apply or `{ globs, funnels, containments, why }`. `containments` names the `feature/journey` journeys that contain a Data or Format door. Each must fail with that kind's seeded door applied and pass without it: write the journey so one of its checks reads the state the door corrupts. + - `journeys`: the repository's journeys by id, each `{ title, entryPoint, run(context) }`, using the check helpers the same import exports. + - `env`, `setup` (commands replacing the build), `userFacing` (globs replacing the kind's default) and `formal` (formal checks: `name`, `covers`, `command`, `violation`, and optionally `behaviour` and `baseCommand`). +- `services`: image, port, readiness command and a `reset` command run before every journey, as for a module. `new` starts them in Docker on free ports; a journey reads them with `context.service(name)` and `context.native(...)`. +- `interface.provide(api, services)` runs in the stub provider after the generic stubs attach, for example to hand a client pointed at `services` to `internal.SetClient`. +- `interface.calls[":"]` calls a plain exported function from the consumer and states the value it must return. +- `interface.providerSide[":"]` names a function only providers call, with the reason. +- `features/.md`: frontmatter lists `covers:` (source globs), `docs:` (pages a verdict judges) and `journeys:`, then the sections Sub-features, How to get to it (user POV), Driving it with antelopejs-verify, and Gotchas. `features/smoke.md` covers nothing and runs on every verdict; here it runs `every-function`. +- `seeded-breaks/.patch`: one minimal product bug per feature that its journeys must catch. +- `seeded-doors/.patch`: one change per door kind that applies. A Data or Format door's patch must break what its containment journeys check. + +```bash +pnpm exec antelopejs-verify map +``` + +`map` fails on a user-facing file no feature covers, a feature without journeys, docs or seeded break, an unknown journey, an applicable door kind without its seeded door, and a Data or Format door whose `containments` name no journey a feature registers. `containments` (below) then runs those journeys against the seeded doors. + +## Verdict rules + +1. **Run every feature whose `covers:` globs the diff touches, plus `smoke`.** `plan` prints that list as `run`. +2. **An unmapped surface fails the verdict.** A changed user-facing file no feature covers needs a feature entry, its journeys and its seeded break in the same PR. +3. **Every registered journey of a feature in `run` must have run and passed.** Run whole features. A journey that could not reach the product is NOT VERIFIED, and so is the verdict, whatever else passed: never PASS and never PASS+NOTES. +4. **Docs.** Judge every `docs:` page of a touched feature against what the journeys showed. A stale page fails the verdict. +5. **Formal checks.** `pnpm exec antelopejs-verify formal ` runs every formal check of `verify.config.ts` that covers the diff, or whose own `files` the diff changes, and records it under `evidence/formal//`. For each one it decides, without judgement, whether the diff changed what the covered code does. `emit` (the default) compares the structure of the covered files' JavaScript on the base and on HEAD: node kinds, names and literal values, leaving out types, comments, formatting, parentheses and `export` keywords. Each use of an imported binding stands for its origin (the resolved module and the imported name), so an import retargeted to another module or alias counts as changed; a function declaration refers to the top-level variables and classes of its own file by file and name, so moving it next to a different variable of the same name counts as changed. Exports are keyed by the origin they export, so a re-export or an `export { f as run }` retargeted to another binding counts as changed. Each file must keep its top-level statements and its side-effect imports in order; function declarations, which are hoisted, may move between covered files with their imports. So a type-only edit, a reformat or a moved function declaration counts as unchanged, while reordering statements or moving any other statement counts as changed. `emit` judges behaviour, not the public surface: exports are the api-diff's job. `probe` runs the check's `behaviour.command` with a worktree of each commit and compares what it prints. Then: + - a covered check that did not run, or exits non-zero (a tool error, or a check that no longer matches its record), fails the verdict; + - recorded findings fail the verdict only when the covered behaviour changed; when it did not, the verdict reports them as known findings; + - when the covered behaviour changed, or the diff changed the check's own `files`, `formal` also runs the check on the base (`baseCommand` when set). A rule the base broke counts as fixed only when HEAD reports it passing (`passing`); a rule the base broke that HEAD does not report at all fails the verdict. A change that leaves no violated rule passes. + + The decision never reads the check's output, so a model whose rule names stay the same still fails on a changed behaviour. It is conservative: a local rename or an extracted helper counts as changed, and code outside `covers` is invisible to it. `--all` also runs the checks that do not cover the diff, without gating. +6. **One-way doors.** The section comes right after the result line and is mandatory. `pnpm exec antelopejs-verify doors` prints the `One-way-door:` line of the diff. Every file the path rules flag needs a door row or a false positive with its reason. A Data or Format row names a `containments` journey that passed in this run, and that journey is proven by `pnpm exec antelopejs-verify containments`: it fails with the kind's seeded door applied. A containment that passes with its door contains nothing, so a Data or Format door without one that fails with its door is not contained. +7. **Result.** FAIL if any rule fails. NOT VERIFIED when any journey of a required feature is NOT VERIFIED, for example a SaaS module run without its sandbox keys, however many other journeys passed. PASS+NOTES when everything required ran and passed but the judgement lists something no journey reached or something the owner should look at. PASS otherwise. + +Write your judgement to `/evidence/judgement.json`: + +```json +{ + "doors": { + "none": "why the diff opens no door", + "rows": [], + "falsePositives": [{ "kind": "publish", "location": "package.json", "reason": "only devDependencies changed" }] + }, + "docs": [{ "page": "README.md", "matches": true, "note": "" }], + "notVerified": ["What no journey reached, and why"], + "attention": ["What the owner should look at that the journeys cannot judge"], + "verifier": "the fresh thread that judged this run, not the author" +} +``` + +A door row is `{ "kind", "location", "irreversible", "containment", "containmentJourney" }`. Then render and publish: + +```bash +pnpm exec antelopejs-verify verdict --pr +pnpm exec antelopejs-verify publish --pr +``` + +`verdict` writes `evidence/verdict.md` and exits 1 unless the result is PASS or PASS+NOTES. `publish` commits the evidence to the orphan branch `verify-evidence` under `pr-//` from a temporary worktree, posts the verdict as a PR comment, sets commit status `verify` on the head SHA (`success` for PASS and PASS+NOTES, `error` for NOT VERIFIED, `failure` for FAIL) and reads it back. It prints the error GitHub answers when the token cannot write statuses. A new push voids the status. Post a verdict only from a thread that did not write the change. + +## Trust + +The skill is not Trusted yet. It becomes Trusted when it catches every seeded break, flags every seeded door, fails every Data and Format containment with its door, passes a shadow run of 10 consecutive agent PRs, and its last maintain pass is newer than the last change to the repository or its `@antelopejs` dependencies and at most 30 days old. + +## Seeded breaks and doors + +```bash +pnpm exec antelopejs-verify seeded +pnpm exec antelopejs-verify seeded-doors +pnpm exec antelopejs-verify containments +``` + +`seeded` runs each feature's journeys in a scratch worktree of HEAD without the patch, as the control, and with `seeded-breaks/.patch` applied. It prints `CAUGHT` only when a journey that passes in the control fails one of its checks with the patch. Journeys failing in the control and failures without a failing check do not count. `seeded-doors` prints `FLAGGED` when the path rules flag each patch with its own kind. `containments` runs every Data and Format containment journey in a scratch worktree of HEAD without the door, as the control, and with `seeded-doors/.patch` applied. It prints `CONTAINED` only when the journey passes in the control and fails one of its checks with the door. `MISSED` means it passes with its door, `UNPROVEN` that it fails in the control or ends without a failing check, and `NO-DOOR` that the kind has no seeded door; the command exits 1 on anything but `CONTAINED`. + +## Gotchas + +- A function the consumer awaits forever, such as a getter no provider resolved, fails after 15 seconds with a `Timeout` value. +- `minor-bump` needs the npm registry. Without it the journey is not verified, never passed. +- A containment journey of a Data or Format door must check what that door's seeded patch changes, for example the arguments the stub provider logged. `containments` reports one that still passes with its door as `MISSED`. +- Runs live under the system temp folder (`TMPDIR`), and the shared caches under `~/.cache/antelopejs-verify`. +- A repository that outgrows this skill runs `antelopejs-sync --verify custom` and keeps its own `.agents/skills/verify-/`. diff --git a/.agents/skills/verify-interface/features/README.md b/.agents/skills/verify-interface/features/README.md new file mode 100644 index 0000000..ddca188 --- /dev/null +++ b/.agents/skills/verify-interface/features/README.md @@ -0,0 +1,18 @@ +# Feature map + +One file per feature. `covers:` lists the source globs it owns, `docs:` the pages a verdict judges, `journeys:` the journeys it runs, and `seeded-breaks/.patch` the break its journeys must catch. `pnpm exec antelopejs-verify map` checks that every user-facing file is covered. + +| Feature | What the user does | Journeys | +|---|---|---| +| [smoke](smoke.md) | The fixed journey every verdict runs, whatever the diff touches: pack the interface, load it in `ajs project dev` with a stub provider and a consumer, and call everything it declares. | every-function | +| [calls](calls.md) | A backend starts, reads, lists, cancels and re-methods a subscription through the five `InterfaceFunction`s, each with an idempotency key positionally and an optional trailing `provider`. | calls-through-core | +| [signatures](signatures.md) | The compile-time contract of the calls: which argument comes where, which are optional, what they resolve to. | signatures | +| [field-types](field-types.md) | The data a backend reads and writes: `Subscription`, `SubscriptionRequest`, `SubscriptionEvent` and the `JsonObject` that `vendorData` is limited to. | field-types | +| [validation-fields](validation-fields.md) | `ValidateSubscriptionRequest` enforces the portable request fields so every implementation refuses the same shapes. | validate-fields | +| [validation-order](validation-order.md) | The error names the first rule broken, so the order of the rules is part of the contract. | validate-order | +| [validation-interval](validation-interval.md) | A period is `intervalCount` times `interval`; the validator refuses what is not a calendar unit and a positive whole count. | validate-interval | +| [validation-dates](validation-dates.md) | Trials and billing anchors defer the first charge; they are mutually exclusive, must be finite, and with `now` must be strictly in the future. | validate-dates | +| [statuses](statuses.md) | The four normalised statuses and why a subscription ended; only `canceled` is terminal. | status-vocabulary | +| [vocabularies](vocabularies.md) | The other strings consumers branch on and providers store: intervals, cancellation timings, event types, the metadata key marking subscription charges, and the one-year period cap. | wire-names | +| [errors](errors.md) | What a consumer catches when a call did not happen: not found, invalid request, invalid state, first period declined. | error-classes | +| [conformance](conformance.md) | The provider conformance suite ships in the package so any module that lists the interface in `antelopeJs.implements` runs it. | suite-helpers, suite-defaults, suite-files | diff --git a/.agents/skills/verify-interface/features/calls.md b/.agents/skills/verify-interface/features/calls.md new file mode 100644 index 0000000..2adf7fb --- /dev/null +++ b/.agents/skills/verify-interface/features/calls.md @@ -0,0 +1,33 @@ +--- +covers: + - src/functions.ts + - src/index.ts +docs: + - README.md + - docs/2.subscriptions.md + - docs/3.events.md +journeys: + - calls-through-core +--- + +# Calls + +A backend starts, reads, lists, cancels and re-methods a subscription through the five `InterfaceFunction`s, each with an idempotency key positionally and an optional trailing `provider`. + +## Sub-features + +- `calls-through-core`: `CreateSubscription`, `GetSubscription`, `ListSubscriptions`, `CancelSubscription` and `UpdateSubscriptionPaymentMethod` are called through core by a consumer module with the arguments a backend passes (the request, the idempotency key positionally, the cancellation timing, the optional trailing `provider` both given and left out); the stub provider must receive the same arguments in the same order. + +## How to get to it (user POV) + +- `await CreateSubscription(request, `subscribe-${accountId}`)` and `await CancelSubscription(id, `cancel-now-${id}`, "now")` in a module next to a billing provider. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" calls +``` + +## Gotchas + +- The stub provider echoes its arguments, so this feature proves what reaches a provider, not what a provider answers. Provider behaviour belongs to its conformance suite. diff --git a/.agents/skills/verify-interface/features/conformance.md b/.agents/skills/verify-interface/features/conformance.md new file mode 100644 index 0000000..992008f --- /dev/null +++ b/.agents/skills/verify-interface/features/conformance.md @@ -0,0 +1,35 @@ +--- +covers: + - src/tests/** +docs: + - README.md + - docs/4.implementing.md +journeys: + - suite-helpers + - suite-defaults + - suite-files +--- + +# Conformance + +The provider conformance suite ships in the package so any module that lists the interface in `antelopeJs.implements` runs it. + +## Sub-features + +- `suite-helpers`: the shipped `tests/support` module of the packed interface, loaded by a provider author through the package's own `exports`, reads `ANTELOPE_PAYMENT_TEST_METHOD`, `ANTELOPE_SUBSCRIPTIONS_TEST_METHOD_DECLINES_LATER`, `ANTELOPE_SUBSCRIPTIONS_TEST_EVENTS` and `ANTELOPE_PAYMENT_TEST_CURRENCY` as README.md states, with every variable set. +- `suite-defaults`: the same module with no variable set: no test method, events not observable and the currency `EUR`; epoch seconds are refused as a time, milliseconds accepted. +- `suite-files`: `dist/tests` holds the two test files and the support module, and `package.json` exports `./tests/*` and ships `dist`. + +## How to get to it (user POV) + +- A provider author runs `ajs module test` in their module with the `ANTELOPE_PAYMENT_TEST_*` variables from the README table. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" conformance +``` + +## Gotchas + +- The suite itself needs a real provider, so these journeys cover its helpers and packaging, not a provider's verdict. diff --git a/.agents/skills/verify-interface/features/errors.md b/.agents/skills/verify-interface/features/errors.md new file mode 100644 index 0000000..987d284 --- /dev/null +++ b/.agents/skills/verify-interface/features/errors.md @@ -0,0 +1,30 @@ +--- +covers: + - src/errors.ts +docs: + - docs/2.subscriptions.md +journeys: + - error-classes +--- + +# Errors + +What a consumer catches when a call did not happen: not found, invalid request, invalid state, first period declined. + +## Sub-features + +- `error-classes`: the five error classes, built the way docs/2.subscriptions.md says (`new InvalidSubscriptionStateError(message, status?, providerCode?)`, `SubscriptionPaymentError(payment, providerCode?)`): each has its own `name`, is a `SubscriptionError` and an `Error`, siblings are not each other, the provider code is kept verbatim, the state error carries the status, the payment error carries the declined `Payment` and the message `The first period could not be charged: payment is `, and the not-found message is `No such subscription: `. + +## How to get to it (user POV) + +- `catch (error) { if (error instanceof SubscriptionPaymentError) show(error.payment.failure) }`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" errors +``` + +## Gotchas + +- A failed renewal is a `past_due` status, not an error; only a declined first period rejects. diff --git a/.agents/skills/verify-interface/features/field-types.md b/.agents/skills/verify-interface/features/field-types.md new file mode 100644 index 0000000..70bbeb4 --- /dev/null +++ b/.agents/skills/verify-interface/features/field-types.md @@ -0,0 +1,31 @@ +--- +covers: + - src/types.ts +docs: + - docs/2.subscriptions.md + - docs/3.events.md +journeys: + - field-types +--- + +# Field types + +The data a backend reads and writes: `Subscription`, `SubscriptionRequest`, `SubscriptionEvent` and the `JsonObject` that `vendorData` is limited to. + +## Sub-features + +- `field-types`: a TypeScript consumer compiling against the packed declarations: the fields of `Subscription`, `SubscriptionRequest` and `SubscriptionEvent` that are required and optional as docs/ state, the literal unions of status, interval, end reason, timing and event type, no `vendorData` in a request, and what `JsonObject` accepts (`undefined` values, nested objects) and refuses (`Date`, `Buffer`, functions, read-only arrays, interface-typed objects). + +## How to get to it (user POV) + +- A TypeScript backend reading `subscription.status`, `subscription.endedAt` and `event.payment`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" field-types +``` + +## Gotchas + +- Types only: the probe cannot see a runtime value. diff --git a/.agents/skills/verify-interface/features/signatures.md b/.agents/skills/verify-interface/features/signatures.md new file mode 100644 index 0000000..7b8c98f --- /dev/null +++ b/.agents/skills/verify-interface/features/signatures.md @@ -0,0 +1,31 @@ +--- +covers: + - src/functions.ts +docs: + - docs/2.subscriptions.md + - docs/4.implementing.md +journeys: + - signatures +--- + +# Signatures + +The compile-time contract of the calls: which argument comes where, which are optional, what they resolve to. + +## Sub-features + +- `signatures`: a TypeScript consumer compiling against the packed declarations: each call takes the arguments docs/2.subscriptions.md shows in order and returns `Promise` (`Promise` for the list), `ValidateSubscriptionRequest(request, now?)` returns `void`, `isTerminalSubscriptionStatus` takes a status, and an event handler takes one `SubscriptionEvent`. + +## How to get to it (user POV) + +- A TypeScript backend module importing the calls and the event handler type. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" signatures +``` + +## Gotchas + +- The probe compiles with `--strict` against `dist/index.d.ts`; it reads types only and needs no provider. diff --git a/.agents/skills/verify-interface/features/smoke.md b/.agents/skills/verify-interface/features/smoke.md new file mode 100644 index 0000000..fb63e47 --- /dev/null +++ b/.agents/skills/verify-interface/features/smoke.md @@ -0,0 +1,30 @@ +--- +covers: +docs: + - README.md + - docs/1.introduction.md +journeys: + - every-function +--- + +# Smoke + +The fixed journey every verdict runs, whatever the diff touches: pack the interface, load it in `ajs project dev` with a stub provider and a consumer, and call everything it declares. + +## Sub-features + +- `every-function`: a fixture project loads the packed interface with a stub provider and a consumer module; every declared call, the event proxy and the two plain helpers (`ValidateSubscriptionRequest` refuses a zero amount with `InvalidSubscriptionRequestError`, `isTerminalSubscriptionStatus` answers true for `canceled` and false for `past_due`) answer through core, and the stub provider logs each call. + +## How to get to it (user POV) + +- A project with a billing module and a module that imports `@antelopejs/interface-subscriptions`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" smoke +``` + +## Gotchas + +- The smoke feature covers no glob, so it never marks a file as mapped. diff --git a/.agents/skills/verify-interface/features/statuses.md b/.agents/skills/verify-interface/features/statuses.md new file mode 100644 index 0000000..9fdb79f --- /dev/null +++ b/.agents/skills/verify-interface/features/statuses.md @@ -0,0 +1,31 @@ +--- +covers: + - src/types.ts +docs: + - docs/2.subscriptions.md + - README.md +journeys: + - status-vocabulary +--- + +# Statuses + +The four normalised statuses and why a subscription ended; only `canceled` is terminal. This journey contains the Format door of `src/types.ts`. + +## Sub-features + +- `status-vocabulary`: the packed `SUBSCRIPTION_STATUSES` lists trialing, active, past_due and canceled in lifecycle order, `SUBSCRIPTION_END_REASONS` lists requested and payment_failed, both are frozen so a consumer cannot extend them, and `isTerminalSubscriptionStatus` is true for `canceled` only. + +## How to get to it (user POV) + +- `if (isTerminalSubscriptionStatus(subscription.status)) stopBilling()` and `switch (subscription.status)`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" statuses +``` + +## Gotchas + +- The strings are stored by providers and consumers, so a rename is a data migration. That is why the door exists. diff --git a/.agents/skills/verify-interface/features/validation-dates.md b/.agents/skills/verify-interface/features/validation-dates.md new file mode 100644 index 0000000..76b1e68 --- /dev/null +++ b/.agents/skills/verify-interface/features/validation-dates.md @@ -0,0 +1,31 @@ +--- +covers: + - src/validation.ts +docs: + - docs/4.implementing.md + - docs/2.subscriptions.md +journeys: + - validate-dates +--- + +# Validation dates + +Trials and billing anchors defer the first charge; they are mutually exclusive, must be finite, and with `now` must be strictly in the future. + +## Sub-features + +- `validate-dates`: with a fixed `now`, `ValidateSubscriptionRequest` accepts a trial end or billing anchor one millisecond ahead and refuses one equal to `now`, behind it, or in epoch seconds; a trial together with an anchor is refused before any date; without `now` a past date passes while `NaN` and infinities are refused; it returns `undefined` on success. + +## How to get to it (user POV) + +- `ValidateSubscriptionRequest({ ...request, trialEnd: Date.now() + 14 * DAY }, Date.now())`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" validation-dates +``` + +## Gotchas + +- `now` is Unix epoch milliseconds, not seconds. diff --git a/.agents/skills/verify-interface/features/validation-fields.md b/.agents/skills/verify-interface/features/validation-fields.md new file mode 100644 index 0000000..08e61dd --- /dev/null +++ b/.agents/skills/verify-interface/features/validation-fields.md @@ -0,0 +1,31 @@ +--- +covers: + - src/validation.ts +docs: + - docs/4.implementing.md + - docs/2.subscriptions.md +journeys: + - validate-fields +--- + +# Validation fields + +`ValidateSubscriptionRequest` enforces the portable request fields so every implementation refuses the same shapes. + +## Sub-features + +- `validate-fields`: the packed `ValidateSubscriptionRequest` accepts the README request and refuses, with `InvalidSubscriptionRequestError` and the message naming the rule, an empty or missing reference, customer or payment method, an amount that is zero, negative, fractional, `NaN`, a string or missing, and a currency that is not three uppercase letters; it checks the format only, so `ZZZ` is accepted. + +## How to get to it (user POV) + +- `ValidateSubscriptionRequest(request, Date.now())` before a request reaches a provider. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" validation-fields +``` + +## Gotchas + +- The error must be an `InvalidSubscriptionRequestError`, a `SubscriptionError` and an `Error`. diff --git a/.agents/skills/verify-interface/features/validation-interval.md b/.agents/skills/verify-interface/features/validation-interval.md new file mode 100644 index 0000000..a37146d --- /dev/null +++ b/.agents/skills/verify-interface/features/validation-interval.md @@ -0,0 +1,31 @@ +--- +covers: + - src/validation.ts +docs: + - docs/4.implementing.md + - docs/2.subscriptions.md +journeys: + - validate-interval +--- + +# Validation interval + +A period is `intervalCount` times `interval`; the validator refuses what is not a calendar unit and a positive whole count. + +## Sub-features + +- `validate-interval`: `ValidateSubscriptionRequest` accepts `day`, `week`, `month` and `year`, an omitted interval count and any positive whole one, and refuses an unknown or differently cased interval and a zero, negative, fractional, `NaN`, text or `null` count. + +## How to get to it (user POV) + +- `{ interval: "month", intervalCount: 3 }` for a quarterly plan. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" validation-interval +``` + +## Gotchas + +- An omitted `intervalCount` means 1 and is accepted; an explicit `null` is not. diff --git a/.agents/skills/verify-interface/features/validation-order.md b/.agents/skills/verify-interface/features/validation-order.md new file mode 100644 index 0000000..500ebb1 --- /dev/null +++ b/.agents/skills/verify-interface/features/validation-order.md @@ -0,0 +1,30 @@ +--- +covers: + - src/validation.ts +docs: + - docs/4.implementing.md +journeys: + - validate-order +--- + +# Validation order + +The error names the first rule broken, so the order of the rules is part of the contract. + +## Sub-features + +- `validate-order`: a request that breaks every rule is repaired one rule at a time; `ValidateSubscriptionRequest` must name reference, customer, payment method, amount, currency, interval, interval count, then the trial and anchor conflict, then the date, as docs/4.implementing.md lists. + +## How to get to it (user POV) + +- A form that shows the first problem `ValidateSubscriptionRequest` reports. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" validation-order +``` + +## Gotchas + +- Each step of the journey repairs exactly one rule, so a swapped pair shows as one wrong message. diff --git a/.agents/skills/verify-interface/features/vocabularies.md b/.agents/skills/verify-interface/features/vocabularies.md new file mode 100644 index 0000000..b52547d --- /dev/null +++ b/.agents/skills/verify-interface/features/vocabularies.md @@ -0,0 +1,31 @@ +--- +covers: + - src/types.ts +docs: + - docs/2.subscriptions.md + - docs/3.events.md +journeys: + - wire-names +--- + +# Vocabularies + +The other strings consumers branch on and providers store: intervals, cancellation timings, event types, the metadata key marking subscription charges, and the one-year period cap. This journey contains the Format door of `src/types.ts`. + +## Sub-features + +- `wire-names`: the packed `SUBSCRIPTION_INTERVALS`, `CANCELLATION_TIMINGS` and `SUBSCRIPTION_EVENT_TYPES` hold the strings docs/ names in the documented order and are frozen, `SUBSCRIPTION_METADATA_KEY` is `antelopeSubscription` and `MAX_PORTABLE_PERIOD_DAYS` is 366. + +## How to get to it (user POV) + +- `if (event?.payment?.metadata?.[SUBSCRIPTION_METADATA_KEY]) return;` in a payment webhook route. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" vocabularies +``` + +## Gotchas + +- The metadata key is how a payment webhook tells a subscription charge from a one-off payment. diff --git a/.agents/skills/verify-interface/journey-kit.ts b/.agents/skills/verify-interface/journey-kit.ts new file mode 100644 index 0000000..a8d32d5 --- /dev/null +++ b/.agents/skills/verify-interface/journey-kit.ts @@ -0,0 +1,126 @@ +import { equal, interrupted } from "@antelopejs/tooling-configs/verify"; +import type { CallRequest, Journey, JourneyContext } from "@antelopejs/tooling-configs/verify"; + +/** Printed by a packed-interface script: a JSON value the journey compares with the docs. */ +export type Printed = string | number | boolean | null | Printed[] | { [key: string]: Printed }; + +/** The request every validation scenario starts from: the README quick start's monthly EUR 19 plan. */ +export const BASE = `{ reference: "acct_1", customer: "cus_1", paymentMethod: "pm_1", amount: { value: 1900, currency: "EUR" }, interval: "month" }`; + +/** A fixed clock, so a date is in the future or the past whatever day the journey runs. */ +export const NOW = 1_800_000_000_000; + +export const PRELUDE = ` +const c = require(process.argv[1]); +const base = ${BASE}; +const NOW = ${NOW}; +const verdict = (request, now) => { + try { + c.ValidateSubscriptionRequest(request, now); + return "accepted"; + } catch (error) { + return [error.name, error.message, error instanceof c.InvalidSubscriptionRequestError, error instanceof c.SubscriptionError, error instanceof Error]; + } +}; +const refused = (request, now) => { + const result = verdict(request, now); + return Array.isArray(result) ? result[1] : result; +}; +`; + +/** Runs a script against the packed `dist/index.js` and checks the JSON it prints against what the docs state. */ +export function packed(title: string, script: string, expected: Printed, label: string): Journey { + return { + title, + entryPoint: "the packed interface required from a consumer", + async run(context: JourneyContext) { + const loaded = await context.command(label, "node", ["-e", `${PRELUDE}\n${script}`, `${context.repo}/dist/index.js`], { + cwd: context.repo, + }); + context.expect(loaded.folder, [ + { name: `${label} ran`, pass: loaded.exitCode === 0, detail: loaded.stderr.slice(-300) }, + equal(`${label} answers as docs/ states`, loaded.stdout.trim(), JSON.stringify(expected)), + ]); + }, + }; +} + +/** The header of a type probe: `Assert>` fails to compile when A and B are not assignable both ways. */ +export const PROBE_HEADER = ` +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type Accepts = [Value] extends [Target] ? true : false; +type Not = T extends true ? false : true; +type Assert = T; +`; + +/** Compiles a probe against the packed declarations in a consumer's strict mode. */ +export function declared(title: string, probe: string, label: string): Journey { + return { + title, + entryPoint: "a TypeScript consumer compiling against the packed declarations", + async run(context) { + const file = `${context.work}/probe.ts`; + context.edit("write the type probe", file, `import type * as Api from "${context.repo}/dist/index";\n${PROBE_HEADER}\n${probe}`); + const compiled = await context.command("compile the type probe", "node", [ + `${context.repo}/node_modules/typescript/bin/tsc`, + "--noEmit", + "--strict", + "--skipLibCheck", + "--target", + "es2022", + "--module", + "commonjs", + "--moduleResolution", + "node", + "--typeRoots", + `${context.repo}/node_modules/@types`, + "--types", + "node", + file, + ]); + context.expect(compiled.folder, [{ name: label, pass: compiled.exitCode === 0, detail: compiled.stdout.slice(0, 600) }]); + }, + }; +} + +/** One consumer call through core: the member to call, its arguments, and the proxy path the stub provider must log. */ +export interface Call { + name: string; + args: NonNullable; +} + +export const REQUEST = { reference: "acct_1", customer: "cus_1", paymentMethod: "pm_1", amount: { value: 1900, currency: "EUR" }, interval: "month" }; + +/** Calls each member of the loaded interface through core with the arguments a backend passes, and checks the stub provider received them in order. */ +export function throughCore(title: string, calls: Call[]): Journey { + return { + title, + entryPoint: "a consumer module calling the packed interface in `ajs project dev`", + async run(context) { + const app = await context.dev("ajs project dev", context.fixture()); + for (const { name, args } of calls) { + const result = await context.call(`call ${name}`, app, { path: [name], args }); + context.expect(result.folder, [ + { name: `${name} resolved through core`, pass: result.ok, detail: JSON.stringify(result.value) }, + equal(`${name} reached the provider with its arguments in order`, result.value, { stub: `.:${name}`, args }), + ]); + } + const stopped = await context.stop("SIGINT", app); + context.expect(stopped.folder, [interrupted(stopped)]); + }, + }; +} + +export function suite(title: string, script: string, expected: Printed, label: string, env: { [name: string]: string } = {}): Journey { + return { + title, + entryPoint: "the conformance suite a provider author imports from the packed interface", + async run(context: JourneyContext) { + const loaded = await context.command(label, "node", ["-e", script], { cwd: context.repo, env }); + context.expect(loaded.folder, [ + { name: `${label} ran`, pass: loaded.exitCode === 0, detail: loaded.stderr.slice(-300) }, + equal(`${label} answers as README.md states`, loaded.stdout.trim(), JSON.stringify(expected)), + ]); + }, + }; +} diff --git a/.agents/skills/verify-interface/package.json b/.agents/skills/verify-interface/package.json new file mode 100644 index 0000000..e986b24 --- /dev/null +++ b/.agents/skills/verify-interface/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/.agents/skills/verify-interface/probes.ts b/.agents/skills/verify-interface/probes.ts new file mode 100644 index 0000000..0f11923 --- /dev/null +++ b/.agents/skills/verify-interface/probes.ts @@ -0,0 +1,52 @@ +export const SIGNATURES = ` +type Req = Api.SubscriptionRequest; +type Sub = Api.Subscription; +type _Create = Assert, [request: Req, idempotencyKey: string, provider?: string]>>; +type _CreateOut = Assert, Promise>>; +type _Get = Assert, [id: string, provider?: string]>>; +type _GetOut = Assert, Promise>>; +type _List = Assert, [customer: string, provider?: string]>>; +type _ListOut = Assert, Promise>>; +type _Cancel = Assert, [id: string, idempotencyKey: string, timing: Api.CancellationTiming, provider?: string]>>; +type _CancelOut = Assert, Promise>>; +type _Update = Assert, [id: string, paymentMethod: string, idempotencyKey: string, provider?: string]>>; +type _UpdateOut = Assert, Promise>>; +type _Validate = Assert void>>; +type _Terminal = Assert boolean>>; +type Handler = Parameters[1]; +type _Handler = Assert>; +type _HandlerOut = Assert, void | Promise>>; +type _HandlerIn = Assert, [event: Api.SubscriptionEvent]>>; +`; + +export const FIELD_TYPES = ` +type Sub = Api.Subscription; +type Req = Api.SubscriptionRequest; +type Required_ = Pick extends Required> ? true : false; +type _Required = Assert>; +type _OptionalTrial = Assert>>; +type _OptionalEnded = Assert>>; +type _OptionalReason = Assert>>; +type _OptionalPayment = Assert>>; +type _OptionalMetadata = Assert>>; +type _OptionalVendor = Assert>>; +type _Status = Assert>; +type _Interval = Assert>; +type _Reason = Assert, "requested" | "payment_failed">>; +type _Times = Assert>; +type _Timing = Assert>; +type _RequestRequired = Assert>; +type _RequestOptional = Assert, Required_, Required_, Required_, Required_], [false, false, false, false, false]>>; +type _NoVendorInRequest = Assert>; +type _VendorReadonly = Assert]: NonNullable[K] }, NonNullable> extends true ? false : true>>; +type _Event = Assert>; +type _EventPayment = Assert>>; +type _EventSubscription = Assert>; +type _Json = Assert>; +type _NoDate = Assert>>; +type _NoBuffer = Assert>>; +type _NoFunction = Assert void }>>>; +type _NoReadonlyArray = Assert>>; +interface Declared { name: string } +type _NoInterface = Assert>>; +`; diff --git a/.agents/skills/verify-interface/seeded-breaks/calls.patch b/.agents/skills/verify-interface/seeded-breaks/calls.patch new file mode 100644 index 0000000..b1db9e1 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/calls.patch @@ -0,0 +1,33 @@ +diff --git a/src/functions.ts b/src/functions.ts +index 3e3ee49..907c48d 100644 +--- a/src/functions.ts ++++ b/src/functions.ts +@@ -84,15 +84,19 @@ export const ListSubscriptions = + * @throws SubscriptionNotFoundError if no subscription has that identifier + * @throws InvalidSubscriptionStateError if the subscription has already ended + */ +-export const CancelSubscription = +- InterfaceFunction< +- ( +- id: string, +- idempotencyKey: string, +- timing: CancellationTiming, +- provider?: string, +- ) => Promise +- >(); ++const Cancel = InterfaceFunction< ++ ( ++ id: string, ++ idempotencyKey: string, ++ timing: CancellationTiming, ++ provider?: string, ++ ) => Promise ++>(); ++export const CancelSubscription = (( ++ id: string, ++ idempotencyKey: string, ++ timing: CancellationTiming, ++) => Cancel(id, idempotencyKey, timing)) as typeof Cancel; + + /** + * Charges a different saved method from the next attempt onwards. diff --git a/.agents/skills/verify-interface/seeded-breaks/conformance.patch b/.agents/skills/verify-interface/seeded-breaks/conformance.patch new file mode 100644 index 0000000..883f0e8 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/conformance.patch @@ -0,0 +1,13 @@ +diff --git a/src/tests/support.ts b/src/tests/support.ts +index b97fbaa..75cd4f1 100644 +--- a/src/tests/support.ts ++++ b/src/tests/support.ts +@@ -73,7 +73,7 @@ export const EVENTS_OBSERVABLE = Boolean( + process.env.ANTELOPE_SUBSCRIPTIONS_TEST_EVENTS, + ); + +-const CURRENCY = process.env.ANTELOPE_PAYMENT_TEST_CURRENCY || "EUR"; ++const CURRENCY = process.env.ANTELOPE_PAYMENT_TEST_CURRENCY || "USD"; + + export const CHARGEABLE = "requires ANTELOPE_PAYMENT_TEST_METHOD"; + export const DECLINES_LATER = diff --git a/.agents/skills/verify-interface/seeded-breaks/errors.patch b/.agents/skills/verify-interface/seeded-breaks/errors.patch new file mode 100644 index 0000000..85c3178 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/errors.patch @@ -0,0 +1,13 @@ +diff --git a/src/errors.ts b/src/errors.ts +index c168617..ef13628 100644 +--- a/src/errors.ts ++++ b/src/errors.ts +@@ -27,7 +27,7 @@ export class SubscriptionError extends Error { + */ + export class SubscriptionNotFoundError extends SubscriptionError { + constructor(id: string, providerCode?: string) { +- super(`No such subscription: ${id}`, providerCode); ++ super(`Subscription not found: ${id}`, providerCode); + this.name = "SubscriptionNotFoundError"; + } + } diff --git a/.agents/skills/verify-interface/seeded-breaks/field-types.patch b/.agents/skills/verify-interface/seeded-breaks/field-types.patch new file mode 100644 index 0000000..5e7fd62 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/field-types.patch @@ -0,0 +1,13 @@ +diff --git a/src/types.ts b/src/types.ts +index 60d7ab8..9e3b2ec 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -158,7 +158,7 @@ export interface Subscription { + */ + currentPeriodEnd: number; + /** Unix epoch milliseconds. Present when the subscription began with a trial. */ +- trialEnd?: number; ++ trialEnd: number; + /** The subscription ends at `currentPeriodEnd` instead of renewing. */ + cancelAtPeriodEnd: boolean; + /** Unix epoch milliseconds. Present exactly when `status` is `canceled`. */ diff --git a/.agents/skills/verify-interface/seeded-breaks/signatures.patch b/.agents/skills/verify-interface/seeded-breaks/signatures.patch new file mode 100644 index 0000000..e21faa6 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/signatures.patch @@ -0,0 +1,13 @@ +diff --git a/src/functions.ts b/src/functions.ts +index 3e3ee49..cab8dff 100644 +--- a/src/functions.ts ++++ b/src/functions.ts +@@ -66,7 +66,7 @@ export const GetSubscription = + */ + export const ListSubscriptions = + InterfaceFunction< +- (customer: string, provider?: string) => Promise ++ (customer: string, provider?: string) => Promise + >(); + + /** diff --git a/.agents/skills/verify-interface/seeded-breaks/smoke.patch b/.agents/skills/verify-interface/seeded-breaks/smoke.patch new file mode 100644 index 0000000..1663643 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/smoke.patch @@ -0,0 +1,18 @@ +diff --git a/src/functions.ts b/src/functions.ts +index 3e3ee49..8628767 100644 +--- a/src/functions.ts ++++ b/src/functions.ts +@@ -54,8 +54,11 @@ export const CreateSubscription = + * @returns The subscription + * @throws SubscriptionNotFoundError if no subscription has that identifier + */ +-export const GetSubscription = +- InterfaceFunction<(id: string, provider?: string) => Promise>(); ++export const GetSubscription = ( ++ id: string, ++ provider?: string, ++): Promise => ++ Promise.reject(new Error(`No provider for ${id} ${provider}`)); + + /** + * Lists a customer's subscriptions, ended ones included. diff --git a/.agents/skills/verify-interface/seeded-breaks/statuses.patch b/.agents/skills/verify-interface/seeded-breaks/statuses.patch new file mode 100644 index 0000000..399bb90 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/statuses.patch @@ -0,0 +1,13 @@ +diff --git a/src/types.ts b/src/types.ts +index 60d7ab8..79fc22b 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -88,7 +88,7 @@ export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number]; + export function isTerminalSubscriptionStatus( + status: SubscriptionStatus, + ): boolean { +- return status === "canceled"; ++ return status === "canceled" || status === "past_due"; + } + + /** diff --git a/.agents/skills/verify-interface/seeded-breaks/validation-dates.patch b/.agents/skills/verify-interface/seeded-breaks/validation-dates.patch new file mode 100644 index 0000000..0801467 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/validation-dates.patch @@ -0,0 +1,13 @@ +diff --git a/src/validation.ts b/src/validation.ts +index 6ac7fb3..3f34eff 100644 +--- a/src/validation.ts ++++ b/src/validation.ts +@@ -10,7 +10,7 @@ function positiveInteger(value: number | undefined): boolean { + function validTime(value: number | undefined, now?: number): boolean { + return ( + value === undefined || +- (Number.isFinite(value) && (now === undefined || value > now)) ++ (Number.isFinite(value) && (now === undefined || value >= now)) + ); + } + diff --git a/.agents/skills/verify-interface/seeded-breaks/validation-fields.patch b/.agents/skills/verify-interface/seeded-breaks/validation-fields.patch new file mode 100644 index 0000000..5ddac63 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/validation-fields.patch @@ -0,0 +1,13 @@ +diff --git a/src/validation.ts b/src/validation.ts +index 6ac7fb3..1d0ae72 100644 +--- a/src/validation.ts ++++ b/src/validation.ts +@@ -25,7 +25,7 @@ const RULES: Rule[] = [ + ? undefined + : "amount must be a positive integer of minor units", + (request) => +- /^[A-Z]{3}$/.test(request.amount?.currency ?? "") ++ /^[A-Za-z]{3}$/.test(request.amount?.currency ?? "") + ? undefined + : "amount.currency must be an uppercase ISO-4217 code", + (request) => diff --git a/.agents/skills/verify-interface/seeded-breaks/validation-interval.patch b/.agents/skills/verify-interface/seeded-breaks/validation-interval.patch new file mode 100644 index 0000000..4bd1d07 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/validation-interval.patch @@ -0,0 +1,13 @@ +diff --git a/src/validation.ts b/src/validation.ts +index 6ac7fb3..5c014fa 100644 +--- a/src/validation.ts ++++ b/src/validation.ts +@@ -34,7 +34,7 @@ const RULES: Rule[] = [ + : `interval must be one of ${SUBSCRIPTION_INTERVALS.join(", ")}`, + (request) => + request.intervalCount === undefined || +- positiveInteger(request.intervalCount) ++ request.intervalCount > 0 + ? undefined + : "intervalCount must be a positive integer", + (request) => diff --git a/.agents/skills/verify-interface/seeded-breaks/validation-order.patch b/.agents/skills/verify-interface/seeded-breaks/validation-order.patch new file mode 100644 index 0000000..75873d2 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/validation-order.patch @@ -0,0 +1,15 @@ +diff --git a/src/validation.ts b/src/validation.ts +index 6ac7fb3..012a3b4 100644 +--- a/src/validation.ts ++++ b/src/validation.ts +@@ -15,9 +15,9 @@ function validTime(value: number | undefined, now?: number): boolean { + } + + const RULES: Rule[] = [ ++ (request) => (request.customer ? undefined : "customer is required"), + (request) => + request.reference ? undefined : "reference must be a non-empty string", +- (request) => (request.customer ? undefined : "customer is required"), + (request) => + request.paymentMethod ? undefined : "paymentMethod is required", + (request) => diff --git a/.agents/skills/verify-interface/seeded-breaks/vocabularies.patch b/.agents/skills/verify-interface/seeded-breaks/vocabularies.patch new file mode 100644 index 0000000..79dc38c --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/vocabularies.patch @@ -0,0 +1,13 @@ +diff --git a/src/types.ts b/src/types.ts +index 60d7ab8..1ed43c2 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -50,7 +50,7 @@ export type SubscriptionInterval = (typeof SUBSCRIPTION_INTERVALS)[number]; + * Providers cap the period differently. A request for a longer one may be + * refused with {@link InvalidSubscriptionRequestError}. + */ +-export const MAX_PORTABLE_PERIOD_DAYS = 366; ++export const MAX_PORTABLE_PERIOD_DAYS = 31; + + /** + * Every value {@link SubscriptionStatus} can take, in lifecycle order. diff --git a/.agents/skills/verify-interface/seeded-doors/contract.patch b/.agents/skills/verify-interface/seeded-doors/contract.patch new file mode 100644 index 0000000..f991702 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/contract.patch @@ -0,0 +1,9 @@ +diff --git a/src/index.ts b/src/index.ts +index c8fedcc..373e570 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -1,4 +1,3 @@ + export * from "./errors"; + export * from "./functions"; + export * from "./types"; +-export * from "./validation"; diff --git a/.agents/skills/verify-interface/seeded-doors/format.patch b/.agents/skills/verify-interface/seeded-doors/format.patch new file mode 100644 index 0000000..e933e27 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/format.patch @@ -0,0 +1,19 @@ +diff --git a/src/types.ts b/src/types.ts +index 60d7ab8..b6b5482 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -58,7 +58,7 @@ export const MAX_PORTABLE_PERIOD_DAYS = 366; + export const SUBSCRIPTION_STATUSES = Object.freeze([ + "trialing", + "active", +- "past_due", ++ "pastdue", + "canceled", + ] as const); + +@@ -295,4 +295,4 @@ export type SubscriptionEventHandler = ( + * `payment.*` events reach your payment webhook route as well. Use this key to + * tell them from one-off payments. + */ +-export const SUBSCRIPTION_METADATA_KEY = "antelopeSubscription"; ++export const SUBSCRIPTION_METADATA_KEY = "subscription"; diff --git a/.agents/skills/verify-interface/seeded-doors/publish.patch b/.agents/skills/verify-interface/seeded-doors/publish.patch new file mode 100644 index 0000000..32e4fcd --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/publish.patch @@ -0,0 +1,13 @@ +diff --git a/package.json b/package.json +index 19f16e9..1d084cc 100644 +--- a/package.json ++++ b/package.json +@@ -43,7 +43,7 @@ + "./package.json": "./package.json" + }, + "publishConfig": { +- "access": "public", ++ "access": "restricted", + "provenance": true + }, + "scripts": { diff --git a/.agents/skills/verify-interface/suite-scripts.ts b/.agents/skills/verify-interface/suite-scripts.ts new file mode 100644 index 0000000..30c8857 --- /dev/null +++ b/.agents/skills/verify-interface/suite-scripts.ts @@ -0,0 +1,33 @@ +export const CONFORMANCE_SCRIPT = ` +globalThis.after = () => undefined; +const own = (name) => require(require.resolve(name, { paths: [process.cwd()] })); +const support = own("@antelopejs/interface-subscriptions/tests/support"); +const seconds = (() => { try { support.assertEpochMillis(Math.floor(Date.now() / 1000), "when"); return "accepted"; } catch (error) { return error.name; } })(); +const millis = (() => { try { support.assertEpochMillis(Date.now(), "when"); return "accepted"; } catch (error) { return error.name; } })(); +const whole = support.inWholeSeconds(60_000) % 1000; +console.log(JSON.stringify({ + method: support.TEST_METHOD ?? null, + declines: support.TEST_METHOD_DECLINES_LATER ?? null, + events: support.EVENTS_OBSERVABLE, + currency: support.amount(5).currency, + value: support.amount(5).value, + absent: support.ABSENT_ID.startsWith("antelope-absent-"), + seconds, + millis, + whole, + day: support.DAY_MS, + trialDays: support.TRIAL_DAYS, + keyDiffers: support.key() !== support.key(), + referenceDiffers: support.reference() !== support.reference(), +})); +`; + +export const CONFORMANCE_FILES = ` +const fs = require("node:fs"); +const path = require("node:path"); +const dist = path.join(process.cwd(), "dist", "tests"); +const files = fs.readdirSync(dist).filter((name) => name.endsWith(".js")).sort(); +const manifest = require(path.join(process.cwd(), "package.json")); +console.log(JSON.stringify({ files, exported: manifest.exports["./tests/*"], shipped: manifest.files })); +`; + diff --git a/.agents/skills/verify-interface/tsconfig.json b/.agents/skills/verify-interface/tsconfig.json new file mode 100644 index 0000000..8d4c934 --- /dev/null +++ b/.agents/skills/verify-interface/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": "../../..", + "module": "nodenext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "composite": false, + "noEmit": true + }, + "include": [ + "**/*.ts" + ] +} diff --git a/.agents/skills/verify-interface/validation-cases.ts b/.agents/skills/verify-interface/validation-cases.ts new file mode 100644 index 0000000..00a40b0 --- /dev/null +++ b/.agents/skills/verify-interface/validation-cases.ts @@ -0,0 +1,170 @@ +import type { Printed } from "./journey-kit.ts"; + +export const FIELD_CASES = ` +const cases = { + base: verdict(base), + referenceEmpty: refused({ ...base, reference: "" }), + referenceMissing: refused({ ...base, reference: undefined }), + customerEmpty: refused({ ...base, customer: "" }), + methodEmpty: refused({ ...base, paymentMethod: "" }), + amountZero: refused({ ...base, amount: { value: 0, currency: "EUR" } }), + amountNegative: refused({ ...base, amount: { value: -5, currency: "EUR" } }), + amountFraction: refused({ ...base, amount: { value: 19.5, currency: "EUR" } }), + amountNaN: refused({ ...base, amount: { value: NaN, currency: "EUR" } }), + amountText: refused({ ...base, amount: { value: "1900", currency: "EUR" } }), + amountMissing: refused({ ...base, amount: undefined }), + currencyLower: refused({ ...base, amount: { value: 1900, currency: "eur" } }), + currencyLong: refused({ ...base, amount: { value: 1900, currency: "EURO" } }), + currencyShort: refused({ ...base, amount: { value: 1900, currency: "EU" } }), + currencyEmpty: refused({ ...base, amount: { value: 1900, currency: "" } }), + currencyUnlisted: refused({ ...base, amount: { value: 1, currency: "ZZZ" } }), + smallestAmount: refused({ ...base, amount: { value: 1, currency: "JPY" } }), + errorKind: verdict({ ...base, reference: "" }), +}; +console.log(JSON.stringify(cases)); +`; + +export const FIELD_EXPECTED: Printed = { + base: "accepted", + referenceEmpty: "reference must be a non-empty string", + referenceMissing: "reference must be a non-empty string", + customerEmpty: "customer is required", + methodEmpty: "paymentMethod is required", + amountZero: "amount must be a positive integer of minor units", + amountNegative: "amount must be a positive integer of minor units", + amountFraction: "amount must be a positive integer of minor units", + amountNaN: "amount must be a positive integer of minor units", + amountText: "amount must be a positive integer of minor units", + amountMissing: "amount must be a positive integer of minor units", + currencyLower: "amount.currency must be an uppercase ISO-4217 code", + currencyLong: "amount.currency must be an uppercase ISO-4217 code", + currencyShort: "amount.currency must be an uppercase ISO-4217 code", + currencyEmpty: "amount.currency must be an uppercase ISO-4217 code", + currencyUnlisted: "accepted", + smallestAmount: "accepted", + errorKind: ["InvalidSubscriptionRequestError", "reference must be a non-empty string", true, true, true], +}; + +export const ORDER_CASES = ` +const broken = { reference: "", customer: "", paymentMethod: "", amount: { value: 0, currency: "x" }, interval: "x", intervalCount: 0, trialEnd: 1, billingAnchor: 2 }; +const order = []; +let request = broken; +for (const key of ["reference", "customer", "paymentMethod", "amount", "currency", "interval", "intervalCount", "trialEnd"]) { + order.push(refused(request, NOW)); + if (key === "reference") request = { ...request, reference: "r" }; + if (key === "customer") request = { ...request, customer: "c" }; + if (key === "paymentMethod") request = { ...request, paymentMethod: "m" }; + if (key === "amount") request = { ...request, amount: { value: 1, currency: "x" } }; + if (key === "currency") request = { ...request, amount: { value: 1, currency: "EUR" } }; + if (key === "interval") request = { ...request, interval: "day" }; + if (key === "intervalCount") request = { ...request, intervalCount: 1 }; + if (key === "trialEnd") request = { ...request, billingAnchor: undefined }; +} +order.push(refused(request, NOW)); +console.log(JSON.stringify(order)); +`; + +export const ORDER_EXPECTED: Printed = [ + "reference must be a non-empty string", + "customer is required", + "paymentMethod is required", + "amount must be a positive integer of minor units", + "amount.currency must be an uppercase ISO-4217 code", + "interval must be one of day, week, month, year", + "intervalCount must be a positive integer", + "trialEnd and billingAnchor are mutually exclusive", + "trialEnd must be a finite timestamp in the future", +]; + +export const INTERVAL_CASES = ` +const cases = { + day: refused({ ...base, interval: "day" }), + week: refused({ ...base, interval: "week" }), + month: refused({ ...base, interval: "month" }), + year: refused({ ...base, interval: "year" }), + fortnight: refused({ ...base, interval: "fortnight" }), + capitalised: refused({ ...base, interval: "Month" }), + empty: refused({ ...base, interval: "" }), + missing: refused({ ...base, interval: undefined }), + countOmitted: refused({ ...base, intervalCount: undefined }), + countOne: refused({ ...base, intervalCount: 1 }), + countTwelve: refused({ ...base, intervalCount: 12 }), + countZero: refused({ ...base, intervalCount: 0 }), + countNegative: refused({ ...base, intervalCount: -1 }), + countFraction: refused({ ...base, intervalCount: 1.5 }), + countNaN: refused({ ...base, intervalCount: NaN }), + countText: refused({ ...base, intervalCount: "2" }), + countNull: refused({ ...base, intervalCount: null }), + vocabulary: [...c.SUBSCRIPTION_INTERVALS], +}; +console.log(JSON.stringify(cases)); +`; + +export const INTERVAL_EXPECTED: Printed = { + day: "accepted", + week: "accepted", + month: "accepted", + year: "accepted", + fortnight: "interval must be one of day, week, month, year", + capitalised: "interval must be one of day, week, month, year", + empty: "interval must be one of day, week, month, year", + missing: "interval must be one of day, week, month, year", + countOmitted: "accepted", + countOne: "accepted", + countTwelve: "accepted", + countZero: "intervalCount must be a positive integer", + countNegative: "intervalCount must be a positive integer", + countFraction: "intervalCount must be a positive integer", + countNaN: "intervalCount must be a positive integer", + countText: "intervalCount must be a positive integer", + countNull: "intervalCount must be a positive integer", + vocabulary: ["day", "week", "month", "year"], +}; + +export const DATE_CASES = ` +const later = NOW + 86_400_000; +const cases = { + trialFuture: refused({ ...base, trialEnd: later }, NOW), + trialNow: refused({ ...base, trialEnd: NOW }, NOW), + trialOneMsLater: refused({ ...base, trialEnd: NOW + 1 }, NOW), + trialOneMsEarlier: refused({ ...base, trialEnd: NOW - 1 }, NOW), + anchorFuture: refused({ ...base, billingAnchor: later }, NOW), + anchorNow: refused({ ...base, billingAnchor: NOW }, NOW), + anchorPast: refused({ ...base, billingAnchor: NOW - 86_400_000 }, NOW), + trialInSeconds: refused({ ...base, trialEnd: Math.floor(NOW / 1000) + 86_400 }, NOW), + bothFuture: refused({ ...base, trialEnd: later, billingAnchor: later }, NOW), + bothWithoutClock: refused({ ...base, trialEnd: later, billingAnchor: later }), + bothPast: refused({ ...base, trialEnd: 1, billingAnchor: 2 }, NOW), + pastWithoutClock: refused({ ...base, trialEnd: 1 }), + anchorPastWithoutClock: refused({ ...base, billingAnchor: 1 }), + trialNaN: refused({ ...base, trialEnd: NaN }), + trialInfinite: refused({ ...base, trialEnd: Infinity }), + anchorNaN: refused({ ...base, billingAnchor: NaN }, NOW), + anchorInfinite: refused({ ...base, billingAnchor: -Infinity }), + neither: refused(base, NOW), + returned: c.ValidateSubscriptionRequest(base, NOW) === undefined, +}; +console.log(JSON.stringify(cases)); +`; + +export const DATE_EXPECTED: Printed = { + trialFuture: "accepted", + trialNow: "trialEnd must be a finite timestamp in the future", + trialOneMsLater: "accepted", + trialOneMsEarlier: "trialEnd must be a finite timestamp in the future", + anchorFuture: "accepted", + anchorNow: "billingAnchor must be a finite timestamp in the future", + anchorPast: "billingAnchor must be a finite timestamp in the future", + trialInSeconds: "trialEnd must be a finite timestamp in the future", + bothFuture: "trialEnd and billingAnchor are mutually exclusive", + bothWithoutClock: "trialEnd and billingAnchor are mutually exclusive", + bothPast: "trialEnd and billingAnchor are mutually exclusive", + pastWithoutClock: "accepted", + anchorPastWithoutClock: "accepted", + trialNaN: "trialEnd must be a finite timestamp in the future", + trialInfinite: "trialEnd must be a finite timestamp in the future", + anchorNaN: "billingAnchor must be a finite timestamp in the future", + anchorInfinite: "billingAnchor must be a finite timestamp in the future", + neither: "accepted", + returned: true, +}; diff --git a/.agents/skills/verify-interface/verify.config.ts b/.agents/skills/verify-interface/verify.config.ts new file mode 100644 index 0000000..7fc5d04 --- /dev/null +++ b/.agents/skills/verify-interface/verify.config.ts @@ -0,0 +1,214 @@ +import type { InterfaceHooks, VerifyConfig } from "@antelopejs/tooling-configs/verify"; + +import type * as SubscriptionsInterface from "../../../src/index.ts"; +import { declared, packed, REQUEST, suite, throughCore } from "./journey-kit.ts"; +import { DATE_CASES, DATE_EXPECTED, FIELD_CASES, FIELD_EXPECTED, INTERVAL_CASES, INTERVAL_EXPECTED, ORDER_CASES, ORDER_EXPECTED } from "./validation-cases.ts"; +import { ERROR_EXPECTED, ERROR_SCRIPT, STATUS_EXPECTED, STATUS_SCRIPT, VOCABULARY_EXPECTED, VOCABULARY_SCRIPT } from "./vocabulary-cases.ts"; +import { FIELD_TYPES, SIGNATURES } from "./probes.ts"; +import { CONFORMANCE_FILES, CONFORMANCE_SCRIPT } from "./suite-scripts.ts"; + +const EUR_19 = { value: 1900, currency: "EUR" }; + +function errorFacts(error: SubscriptionsInterface.SubscriptionError, api: typeof SubscriptionsInterface): [string, string, string | null, boolean] { + return [error.name, error.message, error.providerCode ?? null, error instanceof api.SubscriptionError]; +} + +const CALLS: NonNullable["calls"]> = { + ".:ValidateSubscriptionRequest": { + async call(api) { + try { + api.ValidateSubscriptionRequest({ + reference: "acct_1", + customer: "cus_1", + paymentMethod: "pm_1", + amount: { value: 0, currency: "EUR" }, + interval: "month", + }); + return "accepted"; + } catch (error) { + return error instanceof Error ? error.name : "thrown"; + } + }, + expect: "InvalidSubscriptionRequestError", + }, + ".:SubscriptionError": { + call: (api) => Promise.resolve(errorFacts(new api.SubscriptionError("could not call", "network"), api)), + expect: ["SubscriptionError", "could not call", "network", true], + }, + ".:SubscriptionNotFoundError": { + call: (api) => Promise.resolve(errorFacts(new api.SubscriptionNotFoundError("sub_1", "missing"), api)), + expect: ["SubscriptionNotFoundError", "No such subscription: sub_1", "missing", true], + }, + ".:InvalidSubscriptionRequestError": { + call: (api) => Promise.resolve(errorFacts(new api.InvalidSubscriptionRequestError("bad amount", "invalid"), api)), + expect: ["InvalidSubscriptionRequestError", "bad amount", "invalid", true], + }, + ".:InvalidSubscriptionStateError": { + call: (api) => Promise.resolve(errorFacts(new api.InvalidSubscriptionStateError("ended", "canceled", "state"), api)), + expect: ["InvalidSubscriptionStateError", "ended", "state", true], + }, + ".:SubscriptionPaymentError": { + call: (api) => + Promise.resolve( + errorFacts(new api.SubscriptionPaymentError({ + id: "pay_1", + status: "failed", + amount: EUR_19, + amountCaptured: { value: 0, currency: "EUR" }, + amountRefunded: { value: 0, currency: "EUR" }, + reference: "acct_1", + createdAt: 0, + }, "declined"), api), + ), + expect: ["SubscriptionPaymentError", "The first period could not be charged: payment pay_1 is failed", "declined", true], + }, + ".:isTerminalSubscriptionStatus": { + call: (api) => Promise.resolve([api.isTerminalSubscriptionStatus("canceled"), api.isTerminalSubscriptionStatus("past_due")]), + expect: [true, false], + }, +}; + +const config: VerifyConfig = { + doors: { + data: null, + format: { + globs: ["src/types.ts"], + funnels: [], + containments: ["statuses/status-vocabulary", "vocabularies/wire-names"], + why: "the status, end reason, interval, timing and event strings and the antelopeSubscription metadata key are what providers store in their subscription rows and payment metadata, and what consumers branch on", + }, + contract: { + globs: ["src/index.ts", "src/functions.ts", "package.json"], + funnels: [], + containments: [], + why: "the five calls, the event proxy, the validator, the error classes and the vocabularies are what every billing module and provider imports", + }, + outbound: null, + exposure: null, + publish: { + globs: ["package.json", ".release-it.json", "changelogen.config.json", ".github/workflows/release.yml"], + funnels: [], + containments: [], + why: "the package name and release workflow claim @antelopejs/interface-subscriptions on npm", + }, + }, + interface: { + calls: CALLS, + }, + journeys: { + "calls-through-core": throughCore("the five calls reach the provider with their arguments in order", [ + { name: "CreateSubscription", args: [REQUEST, "subscribe-acct_1"] }, + { name: "CreateSubscription", args: [REQUEST, "subscribe-acct_1", "eu-account"] }, + { name: "GetSubscription", args: ["sub_1"] }, + { name: "GetSubscription", args: ["sub_1", "eu-account"] }, + { name: "ListSubscriptions", args: ["cus_1"] }, + { name: "ListSubscriptions", args: ["cus_1", "eu-account"] }, + { name: "CancelSubscription", args: ["sub_1", "cancel-period-end-sub_1", "period_end"] }, + { name: "CancelSubscription", args: ["sub_1", "cancel-now-sub_1", "now", "eu-account"] }, + { name: "UpdateSubscriptionPaymentMethod", args: ["sub_1", "pm_2", "switch-sub_1"] }, + { name: "UpdateSubscriptionPaymentMethod", args: ["sub_1", "pm_2", "switch-sub_1", "eu-account"] }, + ]), + signatures: declared("every call and the validator have the documented signatures", SIGNATURES, "every export has the documented signature"), + "field-types": declared("Subscription, SubscriptionRequest, SubscriptionEvent and JsonObject have the documented shapes", FIELD_TYPES, "every type has the documented shape"), + "validate-fields": packed( + "ValidateSubscriptionRequest refuses an empty reference, customer or method, a non-positive amount and a malformed currency", + FIELD_CASES, + FIELD_EXPECTED, + "field rules", + ), + "validate-order": packed( + "ValidateSubscriptionRequest names the first rule broken, in the order docs/4.implementing.md lists", + ORDER_CASES, + ORDER_EXPECTED, + "rule order", + ), + "validate-interval": packed( + "ValidateSubscriptionRequest accepts day, week, month and year and a positive whole interval count", + INTERVAL_CASES, + INTERVAL_EXPECTED, + "interval rules", + ), + "validate-dates": packed( + "ValidateSubscriptionRequest refuses both a trial and an anchor, and past or non-finite dates", + DATE_CASES, + DATE_EXPECTED, + "date rules", + ), + "status-vocabulary": packed( + "the four statuses and two end reasons are frozen and only canceled is terminal", + STATUS_SCRIPT, + STATUS_EXPECTED, + "status vocabulary", + ), + "wire-names": packed( + "intervals, timings, event types and the metadata key are the strings docs/ names", + VOCABULARY_SCRIPT, + VOCABULARY_EXPECTED, + "wire names", + ), + "error-classes": packed( + "the error classes carry the names, messages, codes, status and declined payment docs/2.subscriptions.md lists", + ERROR_SCRIPT, + ERROR_EXPECTED, + "error classes", + ), + "suite-helpers": suite( + "the shipped conformance helpers read the environment variables README.md lists", + CONFORMANCE_SCRIPT, + { + method: "tok_ok", + declines: "tok_later", + events: true, + currency: "JPY", + value: 5, + absent: true, + seconds: "AssertionError", + millis: "accepted", + whole: 0, + day: 86_400_000, + trialDays: 14, + keyDiffers: true, + referenceDiffers: true, + }, + "suite helpers with every variable set", + { + ANTELOPE_PAYMENT_TEST_METHOD: "tok_ok", + ANTELOPE_SUBSCRIPTIONS_TEST_METHOD_DECLINES_LATER: "tok_later", + ANTELOPE_SUBSCRIPTIONS_TEST_EVENTS: "1", + ANTELOPE_PAYMENT_TEST_CURRENCY: "JPY", + }, + ), + "suite-defaults": suite( + "without the variables the suite defaults to EUR and skips what needs a provider token", + CONFORMANCE_SCRIPT, + { + method: null, + declines: null, + events: false, + currency: "EUR", + value: 5, + absent: true, + seconds: "AssertionError", + millis: "accepted", + whole: 0, + day: 86_400_000, + trialDays: 14, + keyDiffers: true, + referenceDiffers: true, + }, + "suite helpers with no variable set", + ), + "suite-files": suite( + "the package ships dist/tests and exports it as ./tests/*", + CONFORMANCE_FILES, + { + files: ["subscriptions-changes.test.js", "subscriptions.test.js", "support.js"], + exported: { types: "./dist/tests/*.d.ts", default: "./dist/tests/*.js" }, + shipped: ["dist", "docs", "skills"], + }, + "shipped suite files", + ), + }, +}; + +export default config; diff --git a/.agents/skills/verify-interface/vocabulary-cases.ts b/.agents/skills/verify-interface/vocabulary-cases.ts new file mode 100644 index 0000000..fc7cda9 --- /dev/null +++ b/.agents/skills/verify-interface/vocabulary-cases.ts @@ -0,0 +1,105 @@ +import type { Printed } from "./journey-kit.ts"; + +export const STATUS_SCRIPT = ` +const mutation = (list) => { try { list.push("invented"); return "pushed"; } catch (error) { return error.name; } }; +console.log(JSON.stringify({ + statuses: [...c.SUBSCRIPTION_STATUSES], + terminal: c.SUBSCRIPTION_STATUSES.map((status) => [status, c.isTerminalSubscriptionStatus(status)]), + reasons: [...c.SUBSCRIPTION_END_REASONS], + frozen: [Object.isFrozen(c.SUBSCRIPTION_STATUSES), Object.isFrozen(c.SUBSCRIPTION_END_REASONS)], + push: [mutation(c.SUBSCRIPTION_STATUSES), mutation(c.SUBSCRIPTION_END_REASONS)], + afterPush: [...c.SUBSCRIPTION_STATUSES].length, + unknown: c.isTerminalSubscriptionStatus("deleted"), +})); +`; + +export const STATUS_EXPECTED: Printed = { + statuses: ["trialing", "active", "past_due", "canceled"], + terminal: [ + ["trialing", false], + ["active", false], + ["past_due", false], + ["canceled", true], + ], + reasons: ["requested", "payment_failed"], + frozen: [true, true], + push: ["TypeError", "TypeError"], + afterPush: 4, + unknown: false, +}; + +export const VOCABULARY_SCRIPT = ` +const mutation = (list) => { try { list.push("invented"); return "pushed"; } catch (error) { return error.name; } }; +console.log(JSON.stringify({ + intervals: [...c.SUBSCRIPTION_INTERVALS], + timings: [...c.CANCELLATION_TIMINGS], + events: [...c.SUBSCRIPTION_EVENT_TYPES], + metadataKey: c.SUBSCRIPTION_METADATA_KEY, + maxPeriodDays: c.MAX_PORTABLE_PERIOD_DAYS, + frozen: [c.SUBSCRIPTION_INTERVALS, c.CANCELLATION_TIMINGS, c.SUBSCRIPTION_EVENT_TYPES].map(Object.isFrozen), + push: [c.SUBSCRIPTION_INTERVALS, c.CANCELLATION_TIMINGS, c.SUBSCRIPTION_EVENT_TYPES].map(mutation), +})); +`; + +export const VOCABULARY_EXPECTED: Printed = { + intervals: ["day", "week", "month", "year"], + timings: ["now", "period_end"], + events: ["subscription.created", "subscription.payment_succeeded", "subscription.payment_failed", "subscription.canceled"], + metadataKey: "antelopeSubscription", + maxPeriodDays: 366, + frozen: [true, true, true], + push: ["TypeError", "TypeError", "TypeError"], +}; + +export const ERROR_SCRIPT = ` +const declined = { id: "pay_1", status: "failed", amount: { value: 1900, currency: "EUR" } }; +const describe = (error) => ({ + name: error.name, + message: error.message, + providerCode: error.providerCode ?? null, + base: error instanceof c.SubscriptionError, + error: error instanceof Error, +}); +const missing = new c.SubscriptionNotFoundError("sub_1", "resource_missing"); +const invalid = new c.InvalidSubscriptionRequestError("amount must be positive", "parameter_invalid"); +const state = new c.InvalidSubscriptionStateError("already ended", "canceled", "state_conflict"); +const stateBare = new c.InvalidSubscriptionStateError("already ended"); +const payment = new c.SubscriptionPaymentError(declined, "card_declined"); +const paymentBare = new c.SubscriptionPaymentError({ ...declined, status: "canceled" }); +const root = new c.SubscriptionError("could not call", "network"); +console.log(JSON.stringify({ + base: describe(root), + missing: describe(missing), + missingBare: new c.SubscriptionNotFoundError("sub_2").providerCode ?? null, + invalid: describe(invalid), + siblings: [missing instanceof c.InvalidSubscriptionRequestError, invalid instanceof c.SubscriptionNotFoundError, state instanceof c.SubscriptionPaymentError], + state: { ...describe(state), status: state.status }, + stateBare: { status: stateBare.status ?? null, providerCode: stateBare.providerCode ?? null }, + payment: { ...describe(payment), carried: payment.payment === declined, status: payment.payment.status }, + paymentBare: { message: paymentBare.message, providerCode: paymentBare.providerCode ?? null }, +})); +`; + +export const described = (name: string, message: string, providerCode: string | null) => ({ + name, + message, + providerCode, + base: true, + error: true, +}); + +export const ERROR_EXPECTED: Printed = { + base: described("SubscriptionError", "could not call", "network"), + missing: described("SubscriptionNotFoundError", "No such subscription: sub_1", "resource_missing"), + missingBare: null, + invalid: described("InvalidSubscriptionRequestError", "amount must be positive", "parameter_invalid"), + siblings: [false, false, false], + state: { ...described("InvalidSubscriptionStateError", "already ended", "state_conflict"), status: "canceled" }, + stateBare: { status: null, providerCode: null }, + payment: { + ...described("SubscriptionPaymentError", "The first period could not be charged: payment pay_1 is failed", "card_declined"), + carried: true, + status: "failed", + }, + paymentBare: { message: "The first period could not be charged: payment pay_1 is canceled", providerCode: null }, +}; diff --git a/.claude/skills/verify-interface b/.claude/skills/verify-interface new file mode 120000 index 0000000..c7df9cd --- /dev/null +++ b/.claude/skills/verify-interface @@ -0,0 +1 @@ +../../.agents/skills/verify-interface \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 992f372..e5e19e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,8 @@ The AntelopeJS interface for recurring billing of a saved payment method, publis Finish every change with `pnpm run checks`. It runs typecheck, lint, format, knip, tests, size, suppressions, and sync, and prints only what failed. The change is done when it prints no failure. +Prove a change works with the verification skill in [`.agents/skills/verify-interface/SKILL.md`](.agents/skills/verify-interface/SKILL.md) before you call it done. + The tools enforce most rules, listed in the table at the end. The sections below hold the rules no tool checks. ### Comments From f4d7e5f244d43a2e9e8b0b62ea6592df979a12e0 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Tue, 3 Nov 2026 00:35:19 +0100 Subject: [PATCH 6/7] chore: merge main into campaign/integration (#18) * docs(readme): use one Discord invite (#15) * docs(github): send questions and ideas to Discord (#17) --- .github/ISSUE_TEMPLATE/config.yml | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 9e41a21..9ff80ed 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,8 +4,8 @@ contact_links: url: https://antelopejs.com/ about: Read the documentation before opening a usage issue. - name: Questions and ideas - url: https://github.com/AntelopeJS/antelopejs/discussions - about: Ask for help or discuss an idea with the community. + url: https://discord.gg/g4pFt6eXqu + about: Ask for help or share an idea in #support on the AntelopeJS Discord. - name: Security vulnerability url: https://github.com/AntelopeJS/.github/blob/main/SECURITY.md about: Read the private reporting instructions. Do not open a public issue. diff --git a/README.md b/README.md index 84dd034..c9ca5b4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@
npm License -Discord +Discord Docs
From f54532c702f89cb89da72ed6798ce98dbc942ed7 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Wed, 4 Nov 2026 15:37:20 +0100 Subject: [PATCH 7/7] docs: pass the subscriptions interface docs to the docs standard (#19) * docs: pass the interface-subscriptions docs to the docs standard * docs: turn on the docs standard * docs: correct the fallback vendorData and Stripe conformance claims in the capability matrix --- README.md | 152 ++++++++++---------- docs/1.introduction.md | 124 +++++++++-------- docs/2.subscriptions.md | 147 +++++++++++--------- docs/3.events.md | 75 +++++----- docs/4.implementing.md | 198 +++++++++++++++------------ docs/5.provider-capability-matrix.md | 69 +++++----- package.json | 5 +- 7 files changed, 419 insertions(+), 351 deletions(-) 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 + } } }