@wtfalch/payments 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,12 @@ case) and passes it in; this package never fetches or stores one.
22
22
  import { createStripeProvider, createVippsProvider } from '@wtfalch/payments';
23
23
 
24
24
  const stripe = createStripeProvider({ secretKey }); // sk_...
25
+
26
+ // Either an already-valid access token (0.2.x shape, never refreshed here)...
25
27
  const vipps = createVippsProvider({ subscriptionKey, merchantSerialNumber, accessToken });
28
+ // ...or let the adapter fetch and cache its own (recommended: it refreshes
29
+ // before expiry and shares one in-flight refresh across concurrent calls).
30
+ const vipps2 = createVippsProvider({ subscriptionKey, merchantSerialNumber, clientId, clientSecret });
26
31
 
27
32
  const payment = await stripe.createPayment({
28
33
  reference: 'order-123',
@@ -37,6 +42,7 @@ const payment = await stripe.createPayment({
37
42
  ```ts
38
43
  await stripe.capturePayment(payment.providerReference); // full capture
39
44
  await stripe.refundPayment(payment.providerReference, { reason: 'requested_by_customer' });
45
+ await stripe.cancelPayment(payment.providerReference); // a reserved, uncaptured payment
40
46
  ```
41
47
 
42
48
  ### Recurring agreements
@@ -58,12 +64,98 @@ await vipps.chargeRecurringAgreement(agreement.agreementReference, {
58
64
  amount: { value: 29900, currency: 'NOK' },
59
65
  description: 'March invoice',
60
66
  });
67
+
68
+ // Read the agreement's current status fresh from the provider (e.g. after
69
+ // the payer approves it, or on a schedule) -- what refreshAgreementStatus
70
+ // below calls under the hood.
71
+ await vipps.getRecurringAgreement(agreement.agreementReference);
72
+
73
+ // Stop it (e.g. Archon cancelling a company's subscription) and change its
74
+ // price (a plan change). A caller also tracking this agreement in the store
75
+ // below should follow stopRecurringAgreement with refreshAgreementStatus --
76
+ // or let the resulting agreement.stopped webhook reach applyWebhookEvent --
77
+ // to persist the transition; these two calls only reach the provider.
78
+ await vipps.stopRecurringAgreement(agreement.agreementReference);
79
+ await vipps.updateRecurringAgreement(agreement.agreementReference, {
80
+ amount: { value: 39900, currency: 'NOK' },
81
+ });
82
+
83
+ // Cancel a charge that was created but never settles (e.g. its period was
84
+ // credited before it captured).
85
+ await vipps.cancelRecurringCharge(agreement.agreementReference, chargeReference);
61
86
  ```
62
87
 
63
88
  For Stripe, `createRecurringAgreement` creates a SetupIntent
64
89
  (`agreement.clientSecret` -- confirm with Stripe.js) and
65
90
  `chargeRecurringAgreement` looks up the payment method it saved and charges
66
- it off-session.
91
+ it off-session. `getRecurringAgreement` reads that same SetupIntent back.
92
+ `stopRecurringAgreement` cancels that SetupIntent (there is no Subscription
93
+ object here to cancel); `updateRecurringAgreement` keeps its
94
+ `metadata[amount]` in sync, since a SetupIntent carries no real price of its
95
+ own; `cancelRecurringCharge` cancels the PaymentIntent that
96
+ `chargeRecurringAgreement` created (Stripe has no separate "recurring
97
+ charge" resource).
98
+
99
+ ### Store
100
+
101
+ A provider-neutral Postgres store for the agreement/charge rows a caller
102
+ would otherwise keep in its own schema -- see
103
+ [docs/adr/0006-payment-store.md](../../docs/adr/0006-payment-store.md) for
104
+ the full design and its concurrency-safety argument.
105
+
106
+ ```ts
107
+ import { migrate, createOrGetAgreement, createOrGetCharge, refreshAgreementStatus, applyWebhookEvent } from '@wtfalch/payments';
108
+
109
+ // Once, at startup or in a migration step. `db` must be a single connection
110
+ // (not a pool) -- see db.ts's doc comment.
111
+ await migrate(db);
112
+
113
+ // Idempotent: a second call with the same externalReference returns the
114
+ // same row and never calls the provider again -- but only if the terms
115
+ // match. A retry with a different amount/currency (or, for a charge, a
116
+ // different agreementId) throws PaymentStoreError('mismatched_retry', ...)
117
+ // instead of silently returning the first call's row.
118
+ const agreement = await createOrGetAgreement(db, vipps, {
119
+ externalReference: subscription.id, // the caller's own key
120
+ amount: { value: 29900, currency: 'NOK' },
121
+ productName: 'Pro plan',
122
+ returnUrl: 'https://example.com/agreements/return',
123
+ });
124
+ // agreement.confirmationUrl -- send the payer here
125
+
126
+ // After the payer approves it (e.g. on their return, or on a schedule):
127
+ await refreshAgreementStatus(db, vipps, agreement.id);
128
+
129
+ // Refuses with PaymentStoreError('agreement_not_active', ...) unless the
130
+ // agreement's stored status is "active".
131
+ const charge = await createOrGetCharge(db, vipps, {
132
+ agreementId: agreement.id,
133
+ externalReference: billingCharge.id,
134
+ amount: { value: 29900, currency: 'NOK' },
135
+ description: 'March invoice',
136
+ });
137
+
138
+ // In the webhook handler, after verify*Webhook:
139
+ const applied = await applyWebhookEvent(db, event);
140
+ if (applied.matched === 'charge' && applied.changed && applied.charge?.status === 'captured') {
141
+ // react to the newly captured charge
142
+ }
143
+ ```
144
+
145
+ Status transitions are monotonic: `applyWebhookEvent` and
146
+ `refreshAgreementStatus` gate every write through an explicit per-entity
147
+ allowed-predecessor table, so a delayed, out-of-order webhook delivery (or a
148
+ stale provider read) can never move a charge or agreement's status
149
+ backwards, and a terminal status (`refunded`/`canceled`/`failed`;
150
+ `stopped`/`expired`) never leaves. A rejected transition reports
151
+ `{ changed: false, ignored: 'stale' }` rather than being applied silently --
152
+ this is what keeps a caller's "invoice when `changed && captured`" safe
153
+ under redelivery-without-ordering, not merely defensive. Two concurrent
154
+ deliveries of the same event report exactly one `changed: true`.
155
+
156
+ `db` is a single-connection `Queryable` (`{ query(text, values?) }`) -- a
157
+ `postgres.js` pool's `sql.reserve()`, not the pool itself; see `db.ts`'s doc
158
+ comment for why every mutating store function needs this.
67
159
 
68
160
  ### Webhooks
69
161
 
@@ -110,18 +202,40 @@ From `packages/payments`:
110
202
  pnpm test
111
203
  ```
112
204
 
113
- Every test runs against recorded/fake fixtures (`src/fixtures/{stripe,vipps}`)
114
- and a fake `fetch` (`src/test/fake-fetch.ts`) -- never a live provider call,
115
- never a real key. Webhook verification tests build their own valid signature
116
- independently of the code under test (see `*-webhook.test.ts`), then check
117
- that a tampered payload, a tampered signature, a wrong secret, a wrong
118
- request path (Vipps), and a stale timestamp are all rejected.
119
-
120
- ## Known gap
121
-
122
- The exact `state` enum on a Vipps ePayment response was not confirmed from
123
- primary docs at build time (see `src/vipps.ts`'s module doc comment).
124
- `derivePaymentStatus` falls back to the documented `aggregate.*Amount`
125
- fields when `state` is absent or unrecognised, but this adapter has not been
126
- exercised against a real Vipps sandbox response. Do that before relying on
127
- it for anything that pays out money.
205
+ Adapter and webhook tests run against recorded/fake fixtures
206
+ (`src/fixtures/{stripe,vipps}`) and a fake `fetch` (`src/test/fake-fetch.ts`)
207
+ -- never a live provider call, never a real key. Webhook verification tests
208
+ build their own valid signature independently of the code under test (see
209
+ `*-webhook.test.ts`), then check that a tampered payload, a tampered
210
+ signature, a wrong secret, a wrong request path (Vipps), and a stale
211
+ timestamp are all rejected.
212
+
213
+ Store tests (`store.test.ts`, `migrate.test.ts`) run against PGlite in
214
+ memory by default and against a real Postgres with `TEST_DATABASE_URL` set
215
+ -- against a throwaway container, never a shared one:
216
+
217
+ ```sh
218
+ docker run -d --rm --name payments-pg -e POSTGRES_PASSWORD=postgres -p 5655:5432 postgres:16-alpine
219
+ TEST_DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5655/postgres pnpm test
220
+ docker stop payments-pg
221
+ ```
222
+
223
+ ## Known gaps
224
+
225
+ - The exact `state` enum on a Vipps ePayment response was not confirmed from
226
+ primary docs at build time (see `src/vipps.ts`'s module doc comment).
227
+ `derivePaymentStatus` falls back to the documented `aggregate.*Amount`
228
+ fields when `state` is absent or unrecognised, but this adapter has not
229
+ been exercised against a real Vipps sandbox response. (The Recurring
230
+ agreement `status` enum the store depends on *is* confirmed --
231
+ docs/adr/0006.)
232
+ - No webhook payload shape (ePayment's `name`/`success`, or the Recurring
233
+ API's `eventType`) has been checked against a real, signed Vipps delivery
234
+ -- see docs/adr/0004's "What is not yet confirmed".
235
+ - Neither Recurring event's own event-id field is documented, so
236
+ `eventId` is synthesized from the fields that are (see `vipps.ts`'s
237
+ `normalizeRecurringEvent`) -- fine for logging, not guaranteed
238
+ collision-free the way a real event id would be.
239
+
240
+ Do all of the above before relying on this adapter for anything that pays
241
+ out money.
package/dist/db.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The minimal SQL surface the store needs, so it runs unmodified on PGlite
3
+ * (tests) and on `postgres` (production) without depending on either.
4
+ * Values are bound parameters.
5
+ *
6
+ * Deliberately a structural interface, not a dependency on `@wtfalch/db` --
7
+ * the same choice `@wtfalch/ledger` and `@wtfalch/billing` made in their own
8
+ * `db.ts`. A `Database`/pooled client from that package already satisfies it
9
+ * structurally, so the host injects one and this published package still
10
+ * declares no runtime dependency on it.
11
+ *
12
+ * **Single connection, not a pool.** Every store function in `store.ts`
13
+ * that mutates data issues its own `begin` / `pg_advisory_xact_lock` /
14
+ * `commit` as separate `query()` calls, exactly like `migrate.ts` does --
15
+ * that only wraps a real transaction, and only makes the advisory lock
16
+ * mean anything, when every call in one function invocation lands on the
17
+ * same backend session. A `Queryable` backed by a pool (each `query()` call
18
+ * free to grab a different connection) breaks both guarantees silently,
19
+ * the same trap `migrate()` already carries. Callers on `postgres.js` get
20
+ * one connection via `sql.reserve()`; `src/test/db.ts` shows the pattern.
21
+ */
22
+ export interface Queryable {
23
+ query<T extends Record<string, unknown>>(text: string, values?: unknown[]): Promise<T[]>;
24
+ }
package/dist/db.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/http.d.ts CHANGED
@@ -10,6 +10,10 @@ export interface FetchInit {
10
10
  readonly method?: string;
11
11
  readonly headers?: Readonly<Record<string, string>>;
12
12
  readonly body?: string;
13
+ /** Aborts the request past a deadline -- every adapter request carries one,
14
+ * via `AbortSignal.timeout(...)` (see each adapter's `timeoutMs` option).
15
+ * Optional so a hand-written fake `fetch` in a test can ignore it. */
16
+ readonly signal?: AbortSignal;
13
17
  }
14
18
  export interface FetchResponseLike {
15
19
  readonly ok: boolean;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { CapturePaymentInput, ChargeRecurringAgreementInput, CreatePaymentInput, CreateRecurringAgreementInput, Money, NormalizedWebhookEvent, NormalizedWebhookEventType, PaymentResult, PaymentStatus, RecurringAgreementResult, RecurringAgreementStatus, RefundInput, RefundResult, } from './types.js';
1
+ export type { CancelPaymentInput, CancelRecurringChargeInput, CapturePaymentInput, ChargeRecurringAgreementInput, CreatePaymentInput, CreateRecurringAgreementInput, Money, NormalizedWebhookEvent, NormalizedWebhookEventType, PaymentResult, PaymentStatus, RecurringAgreementResult, RecurringAgreementStatus, RefundInput, RefundResult, StopRecurringAgreementInput, UpdateRecurringAgreementInput, } from './types.js';
2
2
  export { PaymentProviderError, WebhookVerificationError } from './types.js';
3
3
  export type { PaymentProvider } from './provider.js';
4
4
  export type { FetchInit, FetchLike, FetchResponseLike } from './http.js';
@@ -6,3 +6,7 @@ export type { StripeProviderOptions, StripeWebhookOptions } from './stripe.js';
6
6
  export { createStripeProvider, verifyStripeWebhook } from './stripe.js';
7
7
  export type { VippsProviderOptions, VippsWebhookHeaders, VippsWebhookOptions, } from './vipps.js';
8
8
  export { createVippsProvider, verifyVippsWebhook } from './vipps.js';
9
+ export type { Queryable } from './db.js';
10
+ export { migrate } from './migrate.js';
11
+ export type { AppliedWebhookEvent, CreateOrGetAgreementInput, CreateOrGetChargeInput, PaymentAgreement, PaymentCharge, } from './store.js';
12
+ export { applyWebhookEvent, createOrGetAgreement, createOrGetCharge, getAgreementByExternalReference, getAgreementById, getChargeByExternalReference, getChargeById, PaymentStoreError, refreshAgreementStatus, } from './store.js';
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
1
  export { PaymentProviderError, WebhookVerificationError } from './types.js';
2
2
  export { createStripeProvider, verifyStripeWebhook } from './stripe.js';
3
3
  export { createVippsProvider, verifyVippsWebhook } from './vipps.js';
4
+ export { migrate } from './migrate.js';
5
+ export { applyWebhookEvent, createOrGetAgreement, createOrGetCharge, getAgreementByExternalReference, getAgreementById, getChargeByExternalReference, getChargeById, PaymentStoreError, refreshAgreementStatus, } from './store.js';
@@ -0,0 +1,12 @@
1
+ import type { Queryable } from './db.js';
2
+ /**
3
+ * Applies every migration this package ships that `payments_migrations`
4
+ * does not yet record, in file order, one transaction per file. Idempotent:
5
+ * a second call applies nothing and returns `[]`.
6
+ *
7
+ * `db` must be a single connection, not a pool -- a manual `begin`/`commit`
8
+ * issued as separate `query()` calls only wraps the statements between them
9
+ * when every call lands on the same session. `src/test/db.ts` gives this a
10
+ * dedicated connection for that reason.
11
+ */
12
+ export declare function migrate(db: Queryable): Promise<string[]>;
@@ -0,0 +1,88 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ const here = dirname(fileURLToPath(import.meta.url));
5
+ const MIGRATIONS_DIR = join(here, 'migrations');
6
+ const TABLE_SQL = `create table if not exists payments_migrations (
7
+ name text primary key,
8
+ applied_at timestamptz not null default now()
9
+ )`;
10
+ /**
11
+ * One migration file's statements: `--` line comments stripped, then split
12
+ * on `;` -- except inside a `$$...$$` (or `$tag$...$tag$`) dollar-quoted
13
+ * block, which is treated as opaque. Copied from `@wtfalch/ledger`'s (and
14
+ * `@wtfalch/billing`'s) `migrate.ts`, which fixed the same gap in
15
+ * package-template's own `packages/widget` toy splitter. This still assumes
16
+ * no string literal contains `--` or a stray, unmatched `$`.
17
+ */
18
+ function statements(sql) {
19
+ const withoutComments = sql
20
+ .split('\n')
21
+ .map((line) => {
22
+ const index = line.indexOf('--');
23
+ return index === -1 ? line : line.slice(0, index);
24
+ })
25
+ .join('\n');
26
+ const parts = [];
27
+ let current = '';
28
+ let dollarTag = null;
29
+ let i = 0;
30
+ while (i < withoutComments.length) {
31
+ const tagMatch = /^\$[A-Za-z_]*\$/.exec(withoutComments.slice(i));
32
+ if (tagMatch) {
33
+ const tag = tagMatch[0];
34
+ current += tag;
35
+ i += tag.length;
36
+ dollarTag = dollarTag === null ? tag : dollarTag === tag ? null : dollarTag;
37
+ continue;
38
+ }
39
+ const ch = withoutComments[i];
40
+ if (ch === ';' && dollarTag === null) {
41
+ parts.push(current);
42
+ current = '';
43
+ i += 1;
44
+ continue;
45
+ }
46
+ current += ch;
47
+ i += 1;
48
+ }
49
+ if (current.trim().length > 0)
50
+ parts.push(current);
51
+ return parts.map((statement) => statement.trim()).filter((statement) => statement.length > 0);
52
+ }
53
+ /**
54
+ * Applies every migration this package ships that `payments_migrations`
55
+ * does not yet record, in file order, one transaction per file. Idempotent:
56
+ * a second call applies nothing and returns `[]`.
57
+ *
58
+ * `db` must be a single connection, not a pool -- a manual `begin`/`commit`
59
+ * issued as separate `query()` calls only wraps the statements between them
60
+ * when every call lands on the same session. `src/test/db.ts` gives this a
61
+ * dedicated connection for that reason.
62
+ */
63
+ export async function migrate(db) {
64
+ await db.query(TABLE_SQL);
65
+ const applied = new Set((await db.query('select name from payments_migrations')).map((row) => row.name));
66
+ const files = readdirSync(MIGRATIONS_DIR)
67
+ .filter((name) => name.endsWith('.sql'))
68
+ .sort();
69
+ const ran = [];
70
+ for (const file of files) {
71
+ if (applied.has(file))
72
+ continue;
73
+ const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf8');
74
+ await db.query('begin');
75
+ try {
76
+ for (const statement of statements(sql))
77
+ await db.query(statement);
78
+ await db.query('insert into payments_migrations (name) values ($1)', [file]);
79
+ await db.query('commit');
80
+ }
81
+ catch (error) {
82
+ await db.query('rollback');
83
+ throw error;
84
+ }
85
+ ran.push(file);
86
+ }
87
+ return ran;
88
+ }
@@ -0,0 +1,75 @@
1
+ -- @wtfalch/payments: provider-neutral state for a recurring payment
2
+ -- agreement and the charges made against it. One caller (Archon's billing,
3
+ -- today; any other host tomorrow) creates and reads these rows through
4
+ -- store.ts, never SQL of its own -- see docs/adr/0006-payment-store.md for
5
+ -- why this exists (payments v1 shipped with none, docs/adr/0002).
6
+ --
7
+ -- Provider-neutral by design: `provider` is a value, not a table split, so
8
+ -- Stripe, Vipps and a later MobilePay/Stripe-adjacent provider share one
9
+ -- store and one caller-facing shape. Money is integer minor units
10
+ -- (`Money.value` in types.ts), matching the wire format both adapters
11
+ -- already speak -- unlike @wtfalch/ledger/@wtfalch/billing's micros, there
12
+ -- is no larger-than-int4 range to worry about here (a subscription price
13
+ -- never approaches 21 million currency units), so `integer` is enough and,
14
+ -- unlike `numeric`, both PGlite and postgres.js already agree on returning
15
+ -- it as a plain JS `number` -- matching `Money.value: number` with no
16
+ -- driver-specific parsing in store.ts.
17
+ --
18
+ -- Applied by the host's own migrate script after copying this file into its
19
+ -- migrations directory; never edited there. A host without one can instead
20
+ -- apply this package's own migrate() against any Queryable, which is what
21
+ -- src/test/db.ts and src/migrate.ts do.
22
+
23
+ CREATE TABLE payment_agreements (
24
+ id uuid PRIMARY KEY,
25
+ provider text NOT NULL CHECK (provider IN ('stripe', 'vipps')),
26
+ -- The provider's own id for this agreement (Vipps: agreementId; Stripe:
27
+ -- the SetupIntent id). Unique per provider, not globally: Stripe and
28
+ -- Vipps mint ids from unrelated namespaces, so only the pair identifies
29
+ -- one real agreement.
30
+ provider_agreement_id text NOT NULL,
31
+ -- The caller's own key for this agreement, e.g. a billing subscription id.
32
+ -- Globally unique: it is how create-or-get finds "the same request retried"
33
+ -- before ever looking at the provider.
34
+ external_reference text NOT NULL,
35
+ status text NOT NULL CHECK (status IN ('pending', 'active', 'stopped', 'expired')),
36
+ confirmation_url text,
37
+ -- The agreement's price ceiling: the fixed amount for a LEGACY-pricing
38
+ -- agreement, or the payer-accepted suggestedMaxAmount for a VARIABLE one
39
+ -- (CreateRecurringAgreementInput's own doc comment). Every charge against
40
+ -- this agreement must fit under it -- the provider enforces that itself,
41
+ -- this column is this store's own record of what the payer agreed to.
42
+ max_amount_minor integer NOT NULL CHECK (max_amount_minor > 0),
43
+ currency text NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
44
+ created_at timestamptz NOT NULL DEFAULT now(),
45
+ updated_at timestamptz NOT NULL DEFAULT now(),
46
+ UNIQUE (provider, provider_agreement_id),
47
+ UNIQUE (external_reference)
48
+ );
49
+
50
+ CREATE TABLE payment_charges (
51
+ id uuid PRIMARY KEY,
52
+ provider text NOT NULL CHECK (provider IN ('stripe', 'vipps')),
53
+ agreement_id uuid NOT NULL REFERENCES payment_agreements (id),
54
+ -- The provider's own id for this charge (Vipps: chargeId; Stripe: the
55
+ -- PaymentIntent id created off-session against the agreement's saved
56
+ -- payment method). Unique per provider, same reasoning as the agreement's.
57
+ provider_charge_id text NOT NULL,
58
+ -- The caller's own key for this charge, e.g. a billing charge id.
59
+ external_reference text NOT NULL,
60
+ status text NOT NULL CHECK (
61
+ status IN (
62
+ 'pending', 'requires_action', 'authorized', 'captured',
63
+ 'partially_refunded', 'refunded', 'canceled', 'failed'
64
+ )
65
+ ),
66
+ amount_minor integer NOT NULL CHECK (amount_minor > 0),
67
+ currency text NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
68
+ due_date date,
69
+ created_at timestamptz NOT NULL DEFAULT now(),
70
+ updated_at timestamptz NOT NULL DEFAULT now(),
71
+ UNIQUE (provider, provider_charge_id),
72
+ UNIQUE (external_reference)
73
+ );
74
+
75
+ CREATE INDEX payment_charges_agreement_id_idx ON payment_charges (agreement_id);
@@ -1,4 +1,4 @@
1
- import type { CapturePaymentInput, ChargeRecurringAgreementInput, CreatePaymentInput, CreateRecurringAgreementInput, PaymentResult, RecurringAgreementResult, RefundInput, RefundResult } from './types.js';
1
+ import type { CancelPaymentInput, CancelRecurringChargeInput, CapturePaymentInput, ChargeRecurringAgreementInput, CreatePaymentInput, CreateRecurringAgreementInput, PaymentResult, RecurringAgreementResult, RefundInput, RefundResult, StopRecurringAgreementInput, UpdateRecurringAgreementInput } from './types.js';
2
2
  /**
3
3
  * One shape, two adapters (`createStripeProvider`, `createVippsProvider`).
4
4
  * Webhook verification is deliberately not a method here: it needs no
@@ -12,6 +12,46 @@ export interface PaymentProvider {
12
12
  createPayment(input: CreatePaymentInput): Promise<PaymentResult>;
13
13
  capturePayment(providerReference: string, input?: CapturePaymentInput): Promise<PaymentResult>;
14
14
  refundPayment(providerReference: string, input?: RefundInput): Promise<RefundResult>;
15
+ /** Cancels a reserved, uncaptured payment (Vipps: `POST
16
+ * /epayment/v1/payments/{reference}/cancel`; Stripe: `POST
17
+ * /payment_intents/{id}/cancel`) -- a payment this store has not yet
18
+ * captured, left otherwise for the payer to see until it expires. Throws
19
+ * `PaymentProviderError` if the payment is not in a cancelable state
20
+ * (already captured, refunded, or already canceled). */
21
+ cancelPayment(providerReference: string, input?: CancelPaymentInput): Promise<PaymentResult>;
15
22
  createRecurringAgreement(input: CreateRecurringAgreementInput): Promise<RecurringAgreementResult>;
16
23
  chargeRecurringAgreement(agreementReference: string, input: ChargeRecurringAgreementInput): Promise<PaymentResult>;
24
+ /** The agreement's current status, read fresh from the provider -- what
25
+ * `store.ts`'s `refreshAgreementStatus` calls after creating an agreement,
26
+ * since neither adapter's create call itself learns of a payer's later
27
+ * approval/rejection (that arrives by webhook, or by polling this). */
28
+ getRecurringAgreement(agreementReference: string): Promise<RecurringAgreementResult>;
29
+ /** Stops the agreement (Vipps: `PATCH /recurring/v3/agreements/{id}`,
30
+ * `status: STOPPED`, idempotent -- stopping an already-stopped agreement
31
+ * is a no-op; Stripe: cancelling the SetupIntent that backs this
32
+ * adapter's agreement, `POST /setup_intents/{id}/cancel`). A caller that
33
+ * also tracks this agreement in `store.ts` should follow this with
34
+ * `refreshAgreementStatus` (or let the resulting webhook --
35
+ * `agreement.stopped` -- reach `applyWebhookEvent`) to persist the
36
+ * transition; this call only reaches the provider. */
37
+ stopRecurringAgreement(agreementReference: string, input?: StopRecurringAgreementInput): Promise<RecurringAgreementResult>;
38
+ /** Changes the agreement's price (Vipps: the same `PATCH
39
+ * .../agreements/{id}` as `stopRecurringAgreement`, with `pricing.amount`;
40
+ * Stripe: updates the SetupIntent's own `metadata[amount]`, kept in sync
41
+ * for symmetry with Vipps -- see `UpdateRecurringAgreementInput`'s doc
42
+ * comment). Required so a later `chargeRecurringAgreement` above the
43
+ * agreement's old amount is not refused by the provider. */
44
+ updateRecurringAgreement(agreementReference: string, input: UpdateRecurringAgreementInput): Promise<RecurringAgreementResult>;
45
+ /** Cancels a pending/reserved recurring charge that has not yet been
46
+ * captured -- e.g. one created for a billing period that is then credited
47
+ * before it settles (Vipps: `DELETE
48
+ * /recurring/v3/agreements/{agreementId}/charges/{chargeId}`, permitted
49
+ * for a PENDING/DUE/RESERVED charge; Stripe: the charge is a PaymentIntent
50
+ * -- see `chargeRecurringAgreement`'s doc comment -- so this is the same
51
+ * PaymentIntent cancel `cancelPayment` makes). Neither vendor's response
52
+ * gives a body worth normalizing (Vipps: 202/204, no body; Stripe's body
53
+ * is discarded for symmetry) -- the caller learns the resulting status the
54
+ * same way `chargeRecurringAgreement`'s caller already does, via a webhook
55
+ * (`charge.canceled` / `payment.cancelled`) reaching `applyWebhookEvent`. */
56
+ cancelRecurringCharge(agreementReference: string, chargeReference: string, input?: CancelRecurringChargeInput): Promise<void>;
17
57
  }
@@ -0,0 +1,181 @@
1
+ import type { Queryable } from './db.js';
2
+ import type { PaymentProvider } from './provider.js';
3
+ import type { Money, NormalizedWebhookEvent, PaymentStatus, RecurringAgreementStatus } from './types.js';
4
+ /**
5
+ * Provider-neutral store for a recurring payment agreement and the charges
6
+ * made against it -- one `payment_agreements` row per agreement, one
7
+ * `payment_charges` row per charge, `provider` a value on both rather than a
8
+ * table per provider. See docs/adr/0006-payment-store.md for why this
9
+ * exists (v1 shipped none: docs/adr/0002).
10
+ *
11
+ * Every mutating function here takes a single-connection `Queryable` (see
12
+ * `db.ts`'s doc comment) and issues its own `begin` / `pg_advisory_xact_lock`
13
+ * / `commit`, exactly like `migrate.ts`'s transaction-per-file discipline.
14
+ * The lock is what makes create-or-get safe under two callers racing the
15
+ * same `externalReference`: it serializes them onto one connection's
16
+ * transaction for the check, the provider call, and the insert together,
17
+ * so the loser sees the winner's committed row before ever calling the
18
+ * provider, and only the winner calls it at all. This does mean the
19
+ * connection (and the lock) stays held for the duration of one provider
20
+ * HTTP round trip -- deliberate, not an oversight: correctness (never
21
+ * double-creating an agreement/charge at the provider) is worth more here
22
+ * than one connection's throughput. A host running this in a pool must give
23
+ * that connection more room than its adapter's own request timeout
24
+ * (`VippsProviderOptions.timeoutMs` / equivalent), or a slow provider
25
+ * response can trip the pool's own idle-in-transaction guard first.
26
+ */
27
+ /** A store operation refused, or found nothing to act on. Distinct from
28
+ * `PaymentProviderError` (a provider API call itself failed) and
29
+ * `WebhookVerificationError` (a webhook signature did not verify) -- this is
30
+ * the store's own precondition, checked before any provider call is made.
31
+ *
32
+ * `mismatched_retry`: an `externalReference` that already has a row was
33
+ * called again with materially different terms (amount/currency/agreement)
34
+ * -- refused rather than silently returning the first call's row, since a
35
+ * caller retrying with corrected terms (e.g. a fixed pricing bug) must not
36
+ * get back stale data with no indication anything is wrong.
37
+ *
38
+ * `provider_id_conflict`: the provider returned an agreement/charge id that
39
+ * already belongs to a *different* `externalReference` for the same
40
+ * provider -- a provider-side bug or an idempotency-key collision, surfaced
41
+ * as this typed error rather than a raw Postgres unique-violation. */
42
+ export declare class PaymentStoreError extends Error {
43
+ readonly code: 'not_found' | 'agreement_not_active' | 'invalid_request' | 'mismatched_retry' | 'provider_id_conflict';
44
+ constructor(code: PaymentStoreError['code'], message: string);
45
+ }
46
+ export interface PaymentAgreement {
47
+ readonly id: string;
48
+ readonly provider: 'stripe' | 'vipps';
49
+ readonly providerAgreementId: string;
50
+ readonly externalReference: string;
51
+ readonly status: RecurringAgreementStatus;
52
+ readonly confirmationUrl: string | null;
53
+ /** The fixed price (LEGACY) or payer-accepted cap (VARIABLE) this
54
+ * agreement was created with -- see the migration's own doc comment. */
55
+ readonly maxAmountMinor: number;
56
+ readonly currency: string;
57
+ readonly createdAt: Date;
58
+ readonly updatedAt: Date;
59
+ }
60
+ export interface PaymentCharge {
61
+ readonly id: string;
62
+ readonly provider: 'stripe' | 'vipps';
63
+ readonly agreementId: string;
64
+ readonly providerChargeId: string;
65
+ readonly externalReference: string;
66
+ readonly status: PaymentStatus;
67
+ readonly amountMinor: number;
68
+ readonly currency: string;
69
+ readonly dueDate: string | null;
70
+ readonly createdAt: Date;
71
+ readonly updatedAt: Date;
72
+ }
73
+ export interface CreateOrGetAgreementInput {
74
+ readonly externalReference: string;
75
+ readonly amount: Money;
76
+ readonly productName: string;
77
+ readonly returnUrl: string;
78
+ readonly managementUrl?: string;
79
+ readonly interval?: {
80
+ readonly unit: 'day' | 'week' | 'month';
81
+ readonly count: number;
82
+ };
83
+ readonly variablePricing?: {
84
+ readonly suggestedMaxAmount: number;
85
+ };
86
+ }
87
+ /**
88
+ * Finds the agreement for `input.externalReference`, creating it at the
89
+ * provider if this is the first call for that reference. Concurrency-safe
90
+ * under two callers racing the same `externalReference` on separate
91
+ * connections: `pg_advisory_xact_lock(1, hashtext(...))` serializes them,
92
+ * so the loser's check sees the winner's committed row and never calls the
93
+ * provider at all (see this module's doc comment).
94
+ */
95
+ export declare function createOrGetAgreement(db: Queryable, provider: PaymentProvider, input: CreateOrGetAgreementInput): Promise<PaymentAgreement>;
96
+ export interface CreateOrGetChargeInput {
97
+ /** The internal `payment_agreements.id` (not the provider's own id) this
98
+ * charge is against. Must already be `active`. */
99
+ readonly agreementId: string;
100
+ readonly externalReference: string;
101
+ readonly amount: Money;
102
+ readonly description: string;
103
+ /** ISO date (YYYY-MM-DD). Vipps requires at least one day ahead; Stripe ignores it. */
104
+ readonly dueDate?: string;
105
+ }
106
+ /**
107
+ * Finds the charge for `input.externalReference`, creating it at the
108
+ * provider (against `input.agreementId`) if this is the first call for that
109
+ * reference. Refuses with `PaymentStoreError('agreement_not_active', ...)`
110
+ * unless the agreement is `active` -- an agreement the payer has not yet
111
+ * approved, or one that has stopped or expired, has nothing to charge
112
+ * against. Same concurrency-safety shape as `createOrGetAgreement`, keyed
113
+ * on `externalReference` under lock namespace `2` (a charge and an
114
+ * agreement that happen to share a caller-chosen reference string never
115
+ * contend for the same lock).
116
+ */
117
+ export declare function createOrGetCharge(db: Queryable, provider: PaymentProvider, input: CreateOrGetChargeInput): Promise<PaymentCharge>;
118
+ export declare function getAgreementById(db: Queryable, id: string): Promise<PaymentAgreement | null>;
119
+ export declare function getAgreementByExternalReference(db: Queryable, externalReference: string): Promise<PaymentAgreement | null>;
120
+ export declare function getChargeById(db: Queryable, id: string): Promise<PaymentCharge | null>;
121
+ export declare function getChargeByExternalReference(db: Queryable, externalReference: string): Promise<PaymentCharge | null>;
122
+ /**
123
+ * Re-reads `agreementId`'s status from the provider (`getRecurringAgreement`)
124
+ * and persists it if it is a forward transition (`AGREEMENT_ALLOWED_PREDECESSORS`,
125
+ * same table `applyWebhookEvent` uses) from what is stored -- a `stopped`/
126
+ * `expired` agreement never reverts, even if the provider's own read races a
127
+ * webhook that already moved it on, or a bug reports a status that would
128
+ * regress it. No advisory lock: unlike create-or-get, there is no "only one
129
+ * caller may act" requirement here -- `getRecurringAgreement` is a GET,
130
+ * calling it twice concurrently is harmless -- but the write itself is still
131
+ * atomic (a `FOR UPDATE`-gated conditional `UPDATE`, like `transitionAgreement`),
132
+ * so two concurrent refreshes can't race each other into an inconsistent
133
+ * `updated_at`/`confirmation_url` pairing either.
134
+ */
135
+ export declare function refreshAgreementStatus(db: Queryable, provider: PaymentProvider, agreementId: string): Promise<PaymentAgreement>;
136
+ export interface AppliedWebhookEvent {
137
+ /** Which table (if either) the event matched a row in. `'none'` when
138
+ * neither `event.paymentReference` nor `event.agreementReference` named a
139
+ * row this store holds -- not an error, since a webhook may be about a
140
+ * one-off payment this store never tracked, or may arrive before the
141
+ * `createOrGetCharge`/`createOrGetAgreement` call that would have created
142
+ * the row it is about (the caller decides whether to retry later). */
143
+ readonly matched: 'agreement' | 'charge' | 'none';
144
+ /** False when the matched row's status already equalled what this event
145
+ * implies (an ordinary retried delivery), or when the transition was
146
+ * refused as stale (`ignored: 'stale'`) -- either way, no write happened
147
+ * and a caller may safely ignore both. */
148
+ readonly changed: boolean;
149
+ /** Set only when `changed` is false because the event's implied status is
150
+ * not a forward transition (`CHARGE_ALLOWED_PREDECESSORS`/
151
+ * `AGREEMENT_ALLOWED_PREDECESSORS`) from the row's current status -- a
152
+ * delayed, out-of-order delivery arriving after a newer event already
153
+ * moved the row on, or an attempt to leave a terminal status
154
+ * (refunded/canceled/failed; stopped/expired). The row returned is
155
+ * whatever is currently stored, unchanged. */
156
+ readonly ignored?: 'stale';
157
+ readonly agreement?: PaymentAgreement;
158
+ readonly charge?: PaymentCharge;
159
+ readonly previousStatus?: string;
160
+ }
161
+ /**
162
+ * Applies one normalized webhook event to whichever row it is about,
163
+ * idempotently and monotonically: a delivery retried with the same event
164
+ * body updates nothing the second time (`changed: false`), and an event
165
+ * whose implied status is not a forward transition from what is currently
166
+ * stored is refused as stale (`changed: false, ignored: 'stale'`) rather
167
+ * than applied -- see `CHARGE_ALLOWED_PREDECESSORS`'s doc comment for why a
168
+ * webhook transport's redelivery-without-ordering guarantee makes this
169
+ * necessary, not merely defensive. A caller may safely act on every
170
+ * `changed: true` result (e.g. a captured charge triggering an invoice)
171
+ * without its own separate de-duplication or sequencing, including under
172
+ * two concurrent deliveries of the same event (`transitionCharge`'s doc
173
+ * comment).
174
+ *
175
+ * Charge events are checked before agreement events: `event.paymentReference`
176
+ * (a charge id) is more specific than `event.agreementReference`, and the
177
+ * two are mutually exclusive per event in practice (an event about a charge
178
+ * always carries a payment/charge id; an event about the agreement alone
179
+ * never does -- see each adapter's webhook parsing).
180
+ */
181
+ export declare function applyWebhookEvent(db: Queryable, event: NormalizedWebhookEvent): Promise<AppliedWebhookEvent>;