@wtfalch/payments 0.1.0 → 0.3.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 +109 -16
- package/dist/db.d.ts +24 -0
- package/dist/db.js +1 -0
- package/dist/http.d.ts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/migrate.d.ts +12 -0
- package/dist/migrate.js +88 -0
- package/dist/migrations/0001_payments.sql +75 -0
- package/dist/provider.d.ts +5 -0
- package/dist/store.d.ts +181 -0
- package/dist/store.js +534 -0
- package/dist/stripe.js +32 -1
- package/dist/types.d.ts +38 -4
- package/dist/vipps.d.ts +28 -5
- package/dist/vipps.js +174 -19
- package/package.json +13 -13
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',
|
|
@@ -58,12 +63,78 @@ await vipps.chargeRecurringAgreement(agreement.agreementReference, {
|
|
|
58
63
|
amount: { value: 29900, currency: 'NOK' },
|
|
59
64
|
description: 'March invoice',
|
|
60
65
|
});
|
|
66
|
+
|
|
67
|
+
// Read the agreement's current status fresh from the provider (e.g. after
|
|
68
|
+
// the payer approves it, or on a schedule) -- what refreshAgreementStatus
|
|
69
|
+
// below calls under the hood.
|
|
70
|
+
await vipps.getRecurringAgreement(agreement.agreementReference);
|
|
61
71
|
```
|
|
62
72
|
|
|
63
73
|
For Stripe, `createRecurringAgreement` creates a SetupIntent
|
|
64
74
|
(`agreement.clientSecret` -- confirm with Stripe.js) and
|
|
65
75
|
`chargeRecurringAgreement` looks up the payment method it saved and charges
|
|
66
|
-
it off-session.
|
|
76
|
+
it off-session. `getRecurringAgreement` reads that same SetupIntent back.
|
|
77
|
+
|
|
78
|
+
### Store
|
|
79
|
+
|
|
80
|
+
A provider-neutral Postgres store for the agreement/charge rows a caller
|
|
81
|
+
would otherwise keep in its own schema -- see
|
|
82
|
+
[docs/adr/0006-payment-store.md](../../docs/adr/0006-payment-store.md) for
|
|
83
|
+
the full design and its concurrency-safety argument.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { migrate, createOrGetAgreement, createOrGetCharge, refreshAgreementStatus, applyWebhookEvent } from '@wtfalch/payments';
|
|
87
|
+
|
|
88
|
+
// Once, at startup or in a migration step. `db` must be a single connection
|
|
89
|
+
// (not a pool) -- see db.ts's doc comment.
|
|
90
|
+
await migrate(db);
|
|
91
|
+
|
|
92
|
+
// Idempotent: a second call with the same externalReference returns the
|
|
93
|
+
// same row and never calls the provider again -- but only if the terms
|
|
94
|
+
// match. A retry with a different amount/currency (or, for a charge, a
|
|
95
|
+
// different agreementId) throws PaymentStoreError('mismatched_retry', ...)
|
|
96
|
+
// instead of silently returning the first call's row.
|
|
97
|
+
const agreement = await createOrGetAgreement(db, vipps, {
|
|
98
|
+
externalReference: subscription.id, // the caller's own key
|
|
99
|
+
amount: { value: 29900, currency: 'NOK' },
|
|
100
|
+
productName: 'Pro plan',
|
|
101
|
+
returnUrl: 'https://example.com/agreements/return',
|
|
102
|
+
});
|
|
103
|
+
// agreement.confirmationUrl -- send the payer here
|
|
104
|
+
|
|
105
|
+
// After the payer approves it (e.g. on their return, or on a schedule):
|
|
106
|
+
await refreshAgreementStatus(db, vipps, agreement.id);
|
|
107
|
+
|
|
108
|
+
// Refuses with PaymentStoreError('agreement_not_active', ...) unless the
|
|
109
|
+
// agreement's stored status is "active".
|
|
110
|
+
const charge = await createOrGetCharge(db, vipps, {
|
|
111
|
+
agreementId: agreement.id,
|
|
112
|
+
externalReference: billingCharge.id,
|
|
113
|
+
amount: { value: 29900, currency: 'NOK' },
|
|
114
|
+
description: 'March invoice',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// In the webhook handler, after verify*Webhook:
|
|
118
|
+
const applied = await applyWebhookEvent(db, event);
|
|
119
|
+
if (applied.matched === 'charge' && applied.changed && applied.charge?.status === 'captured') {
|
|
120
|
+
// react to the newly captured charge
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Status transitions are monotonic: `applyWebhookEvent` and
|
|
125
|
+
`refreshAgreementStatus` gate every write through an explicit per-entity
|
|
126
|
+
allowed-predecessor table, so a delayed, out-of-order webhook delivery (or a
|
|
127
|
+
stale provider read) can never move a charge or agreement's status
|
|
128
|
+
backwards, and a terminal status (`refunded`/`canceled`/`failed`;
|
|
129
|
+
`stopped`/`expired`) never leaves. A rejected transition reports
|
|
130
|
+
`{ changed: false, ignored: 'stale' }` rather than being applied silently --
|
|
131
|
+
this is what keeps a caller's "invoice when `changed && captured`" safe
|
|
132
|
+
under redelivery-without-ordering, not merely defensive. Two concurrent
|
|
133
|
+
deliveries of the same event report exactly one `changed: true`.
|
|
134
|
+
|
|
135
|
+
`db` is a single-connection `Queryable` (`{ query(text, values?) }`) -- a
|
|
136
|
+
`postgres.js` pool's `sql.reserve()`, not the pool itself; see `db.ts`'s doc
|
|
137
|
+
comment for why every mutating store function needs this.
|
|
67
138
|
|
|
68
139
|
### Webhooks
|
|
69
140
|
|
|
@@ -110,18 +181,40 @@ From `packages/payments`:
|
|
|
110
181
|
pnpm test
|
|
111
182
|
```
|
|
112
183
|
|
|
113
|
-
|
|
114
|
-
and a fake `fetch` (`src/test/fake-fetch.ts`)
|
|
115
|
-
never a real key. Webhook verification tests
|
|
116
|
-
independently of the code under test (see
|
|
117
|
-
that a tampered payload, a tampered
|
|
118
|
-
request path (Vipps), and a stale
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
184
|
+
Adapter and webhook tests run against recorded/fake fixtures
|
|
185
|
+
(`src/fixtures/{stripe,vipps}`) and a fake `fetch` (`src/test/fake-fetch.ts`)
|
|
186
|
+
-- never a live provider call, never a real key. Webhook verification tests
|
|
187
|
+
build their own valid signature independently of the code under test (see
|
|
188
|
+
`*-webhook.test.ts`), then check that a tampered payload, a tampered
|
|
189
|
+
signature, a wrong secret, a wrong request path (Vipps), and a stale
|
|
190
|
+
timestamp are all rejected.
|
|
191
|
+
|
|
192
|
+
Store tests (`store.test.ts`, `migrate.test.ts`) run against PGlite in
|
|
193
|
+
memory by default and against a real Postgres with `TEST_DATABASE_URL` set
|
|
194
|
+
-- against a throwaway container, never a shared one:
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
docker run -d --rm --name payments-pg -e POSTGRES_PASSWORD=postgres -p 5655:5432 postgres:16-alpine
|
|
198
|
+
TEST_DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5655/postgres pnpm test
|
|
199
|
+
docker stop payments-pg
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Known gaps
|
|
203
|
+
|
|
204
|
+
- The exact `state` enum on a Vipps ePayment response was not confirmed from
|
|
205
|
+
primary docs at build time (see `src/vipps.ts`'s module doc comment).
|
|
206
|
+
`derivePaymentStatus` falls back to the documented `aggregate.*Amount`
|
|
207
|
+
fields when `state` is absent or unrecognised, but this adapter has not
|
|
208
|
+
been exercised against a real Vipps sandbox response. (The Recurring
|
|
209
|
+
agreement `status` enum the store depends on *is* confirmed --
|
|
210
|
+
docs/adr/0006.)
|
|
211
|
+
- No webhook payload shape (ePayment's `name`/`success`, or the Recurring
|
|
212
|
+
API's `eventType`) has been checked against a real, signed Vipps delivery
|
|
213
|
+
-- see docs/adr/0004's "What is not yet confirmed".
|
|
214
|
+
- Neither Recurring event's own event-id field is documented, so
|
|
215
|
+
`eventId` is synthesized from the fields that are (see `vipps.ts`'s
|
|
216
|
+
`normalizeRecurringEvent`) -- fine for logging, not guaranteed
|
|
217
|
+
collision-free the way a real event id would be.
|
|
218
|
+
|
|
219
|
+
Do all of the above before relying on this adapter for anything that pays
|
|
220
|
+
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
|
@@ -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[]>;
|
package/dist/migrate.js
ADDED
|
@@ -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);
|
package/dist/provider.d.ts
CHANGED
|
@@ -14,4 +14,9 @@ export interface PaymentProvider {
|
|
|
14
14
|
refundPayment(providerReference: string, input?: RefundInput): Promise<RefundResult>;
|
|
15
15
|
createRecurringAgreement(input: CreateRecurringAgreementInput): Promise<RecurringAgreementResult>;
|
|
16
16
|
chargeRecurringAgreement(agreementReference: string, input: ChargeRecurringAgreementInput): Promise<PaymentResult>;
|
|
17
|
+
/** The agreement's current status, read fresh from the provider -- what
|
|
18
|
+
* `store.ts`'s `refreshAgreementStatus` calls after creating an agreement,
|
|
19
|
+
* since neither adapter's create call itself learns of a payer's later
|
|
20
|
+
* approval/rejection (that arrives by webhook, or by polling this). */
|
|
21
|
+
getRecurringAgreement(agreementReference: string): Promise<RecurringAgreementResult>;
|
|
17
22
|
}
|
package/dist/store.d.ts
ADDED
|
@@ -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>;
|