@wtfalch/payments 0.2.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 +25 -1
- package/dist/types.d.ts +19 -3
- package/dist/vipps.d.ts +28 -5
- package/dist/vipps.js +154 -15
- package/package.json +13 -13
package/dist/store.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Provider-neutral store for a recurring payment agreement and the charges
|
|
4
|
+
* made against it -- one `payment_agreements` row per agreement, one
|
|
5
|
+
* `payment_charges` row per charge, `provider` a value on both rather than a
|
|
6
|
+
* table per provider. See docs/adr/0006-payment-store.md for why this
|
|
7
|
+
* exists (v1 shipped none: docs/adr/0002).
|
|
8
|
+
*
|
|
9
|
+
* Every mutating function here takes a single-connection `Queryable` (see
|
|
10
|
+
* `db.ts`'s doc comment) and issues its own `begin` / `pg_advisory_xact_lock`
|
|
11
|
+
* / `commit`, exactly like `migrate.ts`'s transaction-per-file discipline.
|
|
12
|
+
* The lock is what makes create-or-get safe under two callers racing the
|
|
13
|
+
* same `externalReference`: it serializes them onto one connection's
|
|
14
|
+
* transaction for the check, the provider call, and the insert together,
|
|
15
|
+
* so the loser sees the winner's committed row before ever calling the
|
|
16
|
+
* provider, and only the winner calls it at all. This does mean the
|
|
17
|
+
* connection (and the lock) stays held for the duration of one provider
|
|
18
|
+
* HTTP round trip -- deliberate, not an oversight: correctness (never
|
|
19
|
+
* double-creating an agreement/charge at the provider) is worth more here
|
|
20
|
+
* than one connection's throughput. A host running this in a pool must give
|
|
21
|
+
* that connection more room than its adapter's own request timeout
|
|
22
|
+
* (`VippsProviderOptions.timeoutMs` / equivalent), or a slow provider
|
|
23
|
+
* response can trip the pool's own idle-in-transaction guard first.
|
|
24
|
+
*/
|
|
25
|
+
/** A store operation refused, or found nothing to act on. Distinct from
|
|
26
|
+
* `PaymentProviderError` (a provider API call itself failed) and
|
|
27
|
+
* `WebhookVerificationError` (a webhook signature did not verify) -- this is
|
|
28
|
+
* the store's own precondition, checked before any provider call is made.
|
|
29
|
+
*
|
|
30
|
+
* `mismatched_retry`: an `externalReference` that already has a row was
|
|
31
|
+
* called again with materially different terms (amount/currency/agreement)
|
|
32
|
+
* -- refused rather than silently returning the first call's row, since a
|
|
33
|
+
* caller retrying with corrected terms (e.g. a fixed pricing bug) must not
|
|
34
|
+
* get back stale data with no indication anything is wrong.
|
|
35
|
+
*
|
|
36
|
+
* `provider_id_conflict`: the provider returned an agreement/charge id that
|
|
37
|
+
* already belongs to a *different* `externalReference` for the same
|
|
38
|
+
* provider -- a provider-side bug or an idempotency-key collision, surfaced
|
|
39
|
+
* as this typed error rather than a raw Postgres unique-violation. */
|
|
40
|
+
export class PaymentStoreError extends Error {
|
|
41
|
+
code;
|
|
42
|
+
constructor(code, message) {
|
|
43
|
+
super(`payments store: ${message}`);
|
|
44
|
+
this.name = 'PaymentStoreError';
|
|
45
|
+
this.code = code;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Postgres' SQLSTATE for a unique-violation, surfaced identically by both
|
|
49
|
+
* `postgres.js` and PGlite (both speak the real wire protocol). */
|
|
50
|
+
function isUniqueViolation(error) {
|
|
51
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === '23505';
|
|
52
|
+
}
|
|
53
|
+
function toDate(value) {
|
|
54
|
+
return value instanceof Date ? value : new Date(value);
|
|
55
|
+
}
|
|
56
|
+
function toIsoDate(value) {
|
|
57
|
+
if (value === null)
|
|
58
|
+
return null;
|
|
59
|
+
return value instanceof Date ? value.toISOString().slice(0, 10) : value;
|
|
60
|
+
}
|
|
61
|
+
function agreementFromRow(row) {
|
|
62
|
+
return {
|
|
63
|
+
id: row.id,
|
|
64
|
+
provider: row.provider,
|
|
65
|
+
providerAgreementId: row.provider_agreement_id,
|
|
66
|
+
externalReference: row.external_reference,
|
|
67
|
+
status: row.status,
|
|
68
|
+
confirmationUrl: row.confirmation_url,
|
|
69
|
+
maxAmountMinor: row.max_amount_minor,
|
|
70
|
+
currency: row.currency,
|
|
71
|
+
createdAt: toDate(row.created_at),
|
|
72
|
+
updatedAt: toDate(row.updated_at),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function chargeFromRow(row) {
|
|
76
|
+
return {
|
|
77
|
+
id: row.id,
|
|
78
|
+
provider: row.provider,
|
|
79
|
+
agreementId: row.agreement_id,
|
|
80
|
+
providerChargeId: row.provider_charge_id,
|
|
81
|
+
externalReference: row.external_reference,
|
|
82
|
+
status: row.status,
|
|
83
|
+
amountMinor: row.amount_minor,
|
|
84
|
+
currency: row.currency,
|
|
85
|
+
dueDate: toIsoDate(row.due_date),
|
|
86
|
+
createdAt: toDate(row.created_at),
|
|
87
|
+
updatedAt: toDate(row.updated_at),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function assertMoney(amount) {
|
|
91
|
+
if (!Number.isInteger(amount.value) || amount.value <= 0) {
|
|
92
|
+
throw new PaymentStoreError('invalid_request', `amount.value must be a positive integer (minor units), got ${amount.value}`);
|
|
93
|
+
}
|
|
94
|
+
if (!/^[A-Za-z]{3}$/.test(amount.currency)) {
|
|
95
|
+
throw new PaymentStoreError('invalid_request', `amount.currency must be a 3-letter ISO 4217 code, got ${JSON.stringify(amount.currency)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function assertReference(externalReference) {
|
|
99
|
+
if (externalReference.length < 1 || externalReference.length > 500) {
|
|
100
|
+
throw new PaymentStoreError('invalid_request', 'externalReference must be between 1 and 500 characters');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** The provider's idempotency key for a create-or-get call, namespaced by
|
|
104
|
+
* kind so an agreement and a charge that happen to share a caller-chosen
|
|
105
|
+
* `externalReference` string never collide on the provider's own
|
|
106
|
+
* idempotency store. */
|
|
107
|
+
function idempotencyKeyFor(kind, externalReference) {
|
|
108
|
+
return `payments:${kind}:${externalReference}`;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Finds the agreement for `input.externalReference`, creating it at the
|
|
112
|
+
* provider if this is the first call for that reference. Concurrency-safe
|
|
113
|
+
* under two callers racing the same `externalReference` on separate
|
|
114
|
+
* connections: `pg_advisory_xact_lock(1, hashtext(...))` serializes them,
|
|
115
|
+
* so the loser's check sees the winner's committed row and never calls the
|
|
116
|
+
* provider at all (see this module's doc comment).
|
|
117
|
+
*/
|
|
118
|
+
export async function createOrGetAgreement(db, provider, input) {
|
|
119
|
+
assertReference(input.externalReference);
|
|
120
|
+
assertMoney(input.amount);
|
|
121
|
+
await db.query('begin');
|
|
122
|
+
try {
|
|
123
|
+
await db.query('select pg_advisory_xact_lock(1, hashtext($1))', [input.externalReference]);
|
|
124
|
+
const [existing] = await db.query('select * from payment_agreements where external_reference = $1', [input.externalReference]);
|
|
125
|
+
if (existing) {
|
|
126
|
+
const expectedMaxAmountMinor = input.variablePricing?.suggestedMaxAmount ?? input.amount.value;
|
|
127
|
+
const expectedCurrency = input.amount.currency.toUpperCase();
|
|
128
|
+
if (existing.max_amount_minor !== expectedMaxAmountMinor ||
|
|
129
|
+
existing.currency !== expectedCurrency) {
|
|
130
|
+
throw new PaymentStoreError('mismatched_retry', `externalReference "${input.externalReference}" already has an agreement (maxAmountMinor=${existing.max_amount_minor}, currency=${existing.currency}); this call asked for maxAmountMinor=${expectedMaxAmountMinor}, currency=${expectedCurrency}`);
|
|
131
|
+
}
|
|
132
|
+
await db.query('commit');
|
|
133
|
+
return agreementFromRow(existing);
|
|
134
|
+
}
|
|
135
|
+
const created = await provider.createRecurringAgreement({
|
|
136
|
+
reference: input.externalReference,
|
|
137
|
+
amount: input.amount,
|
|
138
|
+
productName: input.productName,
|
|
139
|
+
returnUrl: input.returnUrl,
|
|
140
|
+
managementUrl: input.managementUrl,
|
|
141
|
+
interval: input.interval,
|
|
142
|
+
variablePricing: input.variablePricing,
|
|
143
|
+
idempotencyKey: idempotencyKeyFor('agreement', input.externalReference),
|
|
144
|
+
});
|
|
145
|
+
const maxAmountMinor = input.variablePricing?.suggestedMaxAmount ?? input.amount.value;
|
|
146
|
+
const currency = input.amount.currency.toUpperCase();
|
|
147
|
+
let inserted;
|
|
148
|
+
try {
|
|
149
|
+
[inserted] = await db.query(`INSERT INTO payment_agreements
|
|
150
|
+
(id, provider, provider_agreement_id, external_reference, status, confirmation_url, max_amount_minor, currency)
|
|
151
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
|
152
|
+
ON CONFLICT (external_reference) DO NOTHING
|
|
153
|
+
RETURNING *`, [
|
|
154
|
+
randomUUID(),
|
|
155
|
+
provider.name,
|
|
156
|
+
created.agreementReference,
|
|
157
|
+
input.externalReference,
|
|
158
|
+
created.status,
|
|
159
|
+
created.confirmationUrl ?? null,
|
|
160
|
+
maxAmountMinor,
|
|
161
|
+
currency,
|
|
162
|
+
]);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (isUniqueViolation(error)) {
|
|
166
|
+
throw new PaymentStoreError('provider_id_conflict', `provider "${provider.name}" agreement id "${created.agreementReference}" already belongs to a different externalReference`);
|
|
167
|
+
}
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
if (inserted) {
|
|
171
|
+
await db.query('commit');
|
|
172
|
+
return agreementFromRow(inserted);
|
|
173
|
+
}
|
|
174
|
+
// Lost the race despite the advisory lock (a caller that skipped it, or
|
|
175
|
+
// a lock-key collision from `hashtext`) -- defense in depth, the same
|
|
176
|
+
// ON CONFLICT + read-back pattern `@wtfalch/billing`'s createCharge uses
|
|
177
|
+
// under its own row lock.
|
|
178
|
+
const [winner] = await db.query('select * from payment_agreements where external_reference = $1', [input.externalReference]);
|
|
179
|
+
await db.query('commit');
|
|
180
|
+
if (!winner) {
|
|
181
|
+
throw new Error('createOrGetAgreement: insert returned no row and no row to read back');
|
|
182
|
+
}
|
|
183
|
+
return agreementFromRow(winner);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
await db.query('rollback');
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Finds the charge for `input.externalReference`, creating it at the
|
|
192
|
+
* provider (against `input.agreementId`) if this is the first call for that
|
|
193
|
+
* reference. Refuses with `PaymentStoreError('agreement_not_active', ...)`
|
|
194
|
+
* unless the agreement is `active` -- an agreement the payer has not yet
|
|
195
|
+
* approved, or one that has stopped or expired, has nothing to charge
|
|
196
|
+
* against. Same concurrency-safety shape as `createOrGetAgreement`, keyed
|
|
197
|
+
* on `externalReference` under lock namespace `2` (a charge and an
|
|
198
|
+
* agreement that happen to share a caller-chosen reference string never
|
|
199
|
+
* contend for the same lock).
|
|
200
|
+
*/
|
|
201
|
+
export async function createOrGetCharge(db, provider, input) {
|
|
202
|
+
assertReference(input.externalReference);
|
|
203
|
+
assertMoney(input.amount);
|
|
204
|
+
await db.query('begin');
|
|
205
|
+
try {
|
|
206
|
+
await db.query('select pg_advisory_xact_lock(2, hashtext($1))', [input.externalReference]);
|
|
207
|
+
const [existing] = await db.query('select * from payment_charges where external_reference = $1', [input.externalReference]);
|
|
208
|
+
if (existing) {
|
|
209
|
+
const expectedCurrency = input.amount.currency.toUpperCase();
|
|
210
|
+
if (existing.agreement_id !== input.agreementId ||
|
|
211
|
+
existing.amount_minor !== input.amount.value ||
|
|
212
|
+
existing.currency !== expectedCurrency) {
|
|
213
|
+
throw new PaymentStoreError('mismatched_retry', `externalReference "${input.externalReference}" already has a charge (agreementId=${existing.agreement_id}, amountMinor=${existing.amount_minor}, currency=${existing.currency}); this call asked for agreementId=${input.agreementId}, amountMinor=${input.amount.value}, currency=${expectedCurrency}`);
|
|
214
|
+
}
|
|
215
|
+
await db.query('commit');
|
|
216
|
+
return chargeFromRow(existing);
|
|
217
|
+
}
|
|
218
|
+
const [agreement] = await db.query('select * from payment_agreements where id = $1 for update', [input.agreementId]);
|
|
219
|
+
if (!agreement) {
|
|
220
|
+
throw new PaymentStoreError('not_found', `agreement ${input.agreementId} not found`);
|
|
221
|
+
}
|
|
222
|
+
if (agreement.status !== 'active') {
|
|
223
|
+
throw new PaymentStoreError('agreement_not_active', `agreement ${input.agreementId} is "${agreement.status}", not "active"`);
|
|
224
|
+
}
|
|
225
|
+
const chargeCurrency = input.amount.currency.toUpperCase();
|
|
226
|
+
if (chargeCurrency !== agreement.currency) {
|
|
227
|
+
// Both adapters' chargeRecurringAgreement ignore a caller-supplied
|
|
228
|
+
// currency and always charge in the agreement's own currency (see
|
|
229
|
+
// vipps.ts's module doc comment) -- a mismatch here would silently
|
|
230
|
+
// store the wrong currency against a charge that was, in reality,
|
|
231
|
+
// billed in the agreement's currency.
|
|
232
|
+
throw new PaymentStoreError('invalid_request', `amount.currency "${chargeCurrency}" does not match agreement ${input.agreementId}'s currency "${agreement.currency}"`);
|
|
233
|
+
}
|
|
234
|
+
const charged = await provider.chargeRecurringAgreement(agreement.provider_agreement_id, {
|
|
235
|
+
amount: input.amount,
|
|
236
|
+
description: input.description,
|
|
237
|
+
dueDate: input.dueDate,
|
|
238
|
+
idempotencyKey: idempotencyKeyFor('charge', input.externalReference),
|
|
239
|
+
});
|
|
240
|
+
const currency = input.amount.currency.toUpperCase();
|
|
241
|
+
let inserted;
|
|
242
|
+
try {
|
|
243
|
+
[inserted] = await db.query(`INSERT INTO payment_charges
|
|
244
|
+
(id, provider, agreement_id, provider_charge_id, external_reference, status, amount_minor, currency, due_date)
|
|
245
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
246
|
+
ON CONFLICT (external_reference) DO NOTHING
|
|
247
|
+
RETURNING *`, [
|
|
248
|
+
randomUUID(),
|
|
249
|
+
provider.name,
|
|
250
|
+
input.agreementId,
|
|
251
|
+
charged.providerReference,
|
|
252
|
+
input.externalReference,
|
|
253
|
+
charged.status,
|
|
254
|
+
input.amount.value,
|
|
255
|
+
currency,
|
|
256
|
+
input.dueDate ?? null,
|
|
257
|
+
]);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
if (isUniqueViolation(error)) {
|
|
261
|
+
throw new PaymentStoreError('provider_id_conflict', `provider "${provider.name}" charge id "${charged.providerReference}" already belongs to a different externalReference`);
|
|
262
|
+
}
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
if (inserted) {
|
|
266
|
+
await db.query('commit');
|
|
267
|
+
return chargeFromRow(inserted);
|
|
268
|
+
}
|
|
269
|
+
const [winner] = await db.query('select * from payment_charges where external_reference = $1', [input.externalReference]);
|
|
270
|
+
await db.query('commit');
|
|
271
|
+
if (!winner) {
|
|
272
|
+
throw new Error('createOrGetCharge: insert returned no row and no row to read back');
|
|
273
|
+
}
|
|
274
|
+
return chargeFromRow(winner);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
await db.query('rollback');
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
export async function getAgreementById(db, id) {
|
|
282
|
+
const [row] = await db.query('select * from payment_agreements where id = $1', [
|
|
283
|
+
id,
|
|
284
|
+
]);
|
|
285
|
+
return row ? agreementFromRow(row) : null;
|
|
286
|
+
}
|
|
287
|
+
export async function getAgreementByExternalReference(db, externalReference) {
|
|
288
|
+
const [row] = await db.query('select * from payment_agreements where external_reference = $1', [externalReference]);
|
|
289
|
+
return row ? agreementFromRow(row) : null;
|
|
290
|
+
}
|
|
291
|
+
export async function getChargeById(db, id) {
|
|
292
|
+
const [row] = await db.query('select * from payment_charges where id = $1', [id]);
|
|
293
|
+
return row ? chargeFromRow(row) : null;
|
|
294
|
+
}
|
|
295
|
+
export async function getChargeByExternalReference(db, externalReference) {
|
|
296
|
+
const [row] = await db.query('select * from payment_charges where external_reference = $1', [externalReference]);
|
|
297
|
+
return row ? chargeFromRow(row) : null;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* The forward-transition graph both `applyWebhookEvent` and
|
|
301
|
+
* `refreshAgreementStatus` gate every write through: `ALLOWED_PREDECESSORS[X]`
|
|
302
|
+
* lists every status a row may currently hold for a write *to* `X` to be
|
|
303
|
+
* accepted -- always including `X` itself (an idempotent no-op redelivery
|
|
304
|
+
* or re-read is always "allowed"). A status with no entry pointing *to* it
|
|
305
|
+
* from anywhere else is terminal: `refunded`/`canceled`/`failed` for a
|
|
306
|
+
* charge, `stopped`/`expired` for an agreement never appear in any other
|
|
307
|
+
* status's list, so nothing can ever transition a row back out of them.
|
|
308
|
+
*
|
|
309
|
+
* This exists because a webhook transport is at-least-once and gives no
|
|
310
|
+
* ordering guarantee: a delayed, older event (e.g. `charge.reserved`,
|
|
311
|
+
* meaning "pending") can be redelivered *after* a newer one
|
|
312
|
+
* (`charge.captured`) already landed. Without this table, that stale
|
|
313
|
+
* delivery would move status backwards -- and a *second*, entirely normal
|
|
314
|
+
* redelivery of the original `captured` event would then see the row is no
|
|
315
|
+
* longer `captured` and report `changed: true` a second time, which is
|
|
316
|
+
* exactly the shape of bug that makes a caller invoicing on "changed &&
|
|
317
|
+
* captured" double-invoice. Comparing against `ALLOWED_PREDECESSORS` instead
|
|
318
|
+
* of a plain `!==` check is what keeps a redelivery-after-a-stale-write
|
|
319
|
+
* idempotent instead of a second "real" change.
|
|
320
|
+
*/
|
|
321
|
+
const CHARGE_ALLOWED_PREDECESSORS = {
|
|
322
|
+
pending: ['pending'],
|
|
323
|
+
requires_action: ['pending', 'requires_action'],
|
|
324
|
+
authorized: ['pending', 'requires_action', 'authorized'],
|
|
325
|
+
captured: ['pending', 'requires_action', 'authorized', 'captured'],
|
|
326
|
+
partially_refunded: ['captured', 'partially_refunded'],
|
|
327
|
+
refunded: ['captured', 'partially_refunded', 'refunded'],
|
|
328
|
+
canceled: ['pending', 'requires_action', 'authorized', 'canceled'],
|
|
329
|
+
failed: ['pending', 'requires_action', 'authorized', 'failed'],
|
|
330
|
+
};
|
|
331
|
+
const AGREEMENT_ALLOWED_PREDECESSORS = {
|
|
332
|
+
pending: ['pending'],
|
|
333
|
+
active: ['pending', 'active'],
|
|
334
|
+
stopped: ['pending', 'active', 'stopped'],
|
|
335
|
+
expired: ['pending', 'active', 'expired'],
|
|
336
|
+
};
|
|
337
|
+
/**
|
|
338
|
+
* Atomically reads `payment_charges`' current status and, only if it is one
|
|
339
|
+
* of `CHARGE_ALLOWED_PREDECESSORS[targetStatus]`, writes `targetStatus` --
|
|
340
|
+
* in one statement, so two concurrent deliveries of the same event can
|
|
341
|
+
* never both observe "not yet at the target" and both report a change: the
|
|
342
|
+
* `FOR UPDATE` inside the CTE serializes them onto the same row, and the
|
|
343
|
+
* loser re-evaluates its own `WHERE` against the winner's already-committed
|
|
344
|
+
* value.
|
|
345
|
+
*/
|
|
346
|
+
async function transitionCharge(db, providerName, providerChargeId, targetStatus) {
|
|
347
|
+
const predecessors = CHARGE_ALLOWED_PREDECESSORS[targetStatus];
|
|
348
|
+
const [updated] = await db.query(`WITH current AS (
|
|
349
|
+
SELECT status FROM payment_charges
|
|
350
|
+
WHERE provider = $1 AND provider_charge_id = $2
|
|
351
|
+
FOR UPDATE
|
|
352
|
+
)
|
|
353
|
+
UPDATE payment_charges c
|
|
354
|
+
SET status = $3,
|
|
355
|
+
updated_at = CASE WHEN current.status IS DISTINCT FROM $3 THEN now() ELSE c.updated_at END
|
|
356
|
+
FROM current
|
|
357
|
+
WHERE c.provider = $1 AND c.provider_charge_id = $2
|
|
358
|
+
AND current.status = ANY($4::text[])
|
|
359
|
+
RETURNING c.*, current.status AS previous_status`, [providerName, providerChargeId, targetStatus, predecessors]);
|
|
360
|
+
if (updated) {
|
|
361
|
+
return {
|
|
362
|
+
kind: 'applied',
|
|
363
|
+
row: updated,
|
|
364
|
+
previousStatus: updated.previous_status,
|
|
365
|
+
changed: updated.previous_status !== targetStatus,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
const [row] = await db.query('select * from payment_charges where provider = $1 and provider_charge_id = $2', [providerName, providerChargeId]);
|
|
369
|
+
return row ? { kind: 'stale', row } : { kind: 'not_found' };
|
|
370
|
+
}
|
|
371
|
+
/** Same shape as `transitionCharge`, for `payment_agreements`. */
|
|
372
|
+
async function transitionAgreement(db, providerName, providerAgreementId, targetStatus) {
|
|
373
|
+
const predecessors = AGREEMENT_ALLOWED_PREDECESSORS[targetStatus];
|
|
374
|
+
const [updated] = await db.query(`WITH current AS (
|
|
375
|
+
SELECT status FROM payment_agreements
|
|
376
|
+
WHERE provider = $1 AND provider_agreement_id = $2
|
|
377
|
+
FOR UPDATE
|
|
378
|
+
)
|
|
379
|
+
UPDATE payment_agreements a
|
|
380
|
+
SET status = $3,
|
|
381
|
+
updated_at = CASE WHEN current.status IS DISTINCT FROM $3 THEN now() ELSE a.updated_at END
|
|
382
|
+
FROM current
|
|
383
|
+
WHERE a.provider = $1 AND a.provider_agreement_id = $2
|
|
384
|
+
AND current.status = ANY($4::text[])
|
|
385
|
+
RETURNING a.*, current.status AS previous_status`, [providerName, providerAgreementId, targetStatus, predecessors]);
|
|
386
|
+
if (updated) {
|
|
387
|
+
return {
|
|
388
|
+
kind: 'applied',
|
|
389
|
+
row: updated,
|
|
390
|
+
previousStatus: updated.previous_status,
|
|
391
|
+
changed: updated.previous_status !== targetStatus,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
const [row] = await db.query('select * from payment_agreements where provider = $1 and provider_agreement_id = $2', [providerName, providerAgreementId]);
|
|
395
|
+
return row ? { kind: 'stale', row } : { kind: 'not_found' };
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Re-reads `agreementId`'s status from the provider (`getRecurringAgreement`)
|
|
399
|
+
* and persists it if it is a forward transition (`AGREEMENT_ALLOWED_PREDECESSORS`,
|
|
400
|
+
* same table `applyWebhookEvent` uses) from what is stored -- a `stopped`/
|
|
401
|
+
* `expired` agreement never reverts, even if the provider's own read races a
|
|
402
|
+
* webhook that already moved it on, or a bug reports a status that would
|
|
403
|
+
* regress it. No advisory lock: unlike create-or-get, there is no "only one
|
|
404
|
+
* caller may act" requirement here -- `getRecurringAgreement` is a GET,
|
|
405
|
+
* calling it twice concurrently is harmless -- but the write itself is still
|
|
406
|
+
* atomic (a `FOR UPDATE`-gated conditional `UPDATE`, like `transitionAgreement`),
|
|
407
|
+
* so two concurrent refreshes can't race each other into an inconsistent
|
|
408
|
+
* `updated_at`/`confirmation_url` pairing either.
|
|
409
|
+
*/
|
|
410
|
+
export async function refreshAgreementStatus(db, provider, agreementId) {
|
|
411
|
+
const [existing] = await db.query('select * from payment_agreements where id = $1', [agreementId]);
|
|
412
|
+
if (!existing)
|
|
413
|
+
throw new PaymentStoreError('not_found', `agreement ${agreementId} not found`);
|
|
414
|
+
const fresh = await provider.getRecurringAgreement(existing.provider_agreement_id);
|
|
415
|
+
const predecessors = AGREEMENT_ALLOWED_PREDECESSORS[fresh.status];
|
|
416
|
+
const [updated] = await db.query(`WITH current AS (
|
|
417
|
+
SELECT status, confirmation_url FROM payment_agreements WHERE id = $1 FOR UPDATE
|
|
418
|
+
)
|
|
419
|
+
UPDATE payment_agreements a
|
|
420
|
+
SET status = $2,
|
|
421
|
+
confirmation_url = coalesce($3, a.confirmation_url),
|
|
422
|
+
updated_at = CASE
|
|
423
|
+
WHEN current.status IS DISTINCT FROM $2
|
|
424
|
+
OR current.confirmation_url IS DISTINCT FROM coalesce($3, current.confirmation_url)
|
|
425
|
+
THEN now() ELSE a.updated_at END
|
|
426
|
+
FROM current
|
|
427
|
+
WHERE a.id = $1 AND current.status = ANY($4::text[])
|
|
428
|
+
RETURNING a.*`, [agreementId, fresh.status, fresh.confirmationUrl ?? null, predecessors]);
|
|
429
|
+
if (updated)
|
|
430
|
+
return agreementFromRow(updated);
|
|
431
|
+
// Not a forward transition (or the provider's read raced a concurrent
|
|
432
|
+
// write past it) -- leave the row exactly as stored; terminal statuses
|
|
433
|
+
// never leave.
|
|
434
|
+
const [current] = await db.query('select * from payment_agreements where id = $1', [
|
|
435
|
+
agreementId,
|
|
436
|
+
]);
|
|
437
|
+
if (!current)
|
|
438
|
+
throw new Error('refreshAgreementStatus: agreement disappeared mid-refresh');
|
|
439
|
+
return agreementFromRow(current);
|
|
440
|
+
}
|
|
441
|
+
/** What a webhook event, once matched to a row, means for that row's
|
|
442
|
+
* status. Deliberately provider-agnostic: Stripe's SetupIntent/PaymentIntent
|
|
443
|
+
* events and Vipps' Recurring events both normalize to these same type
|
|
444
|
+
* names (see stripe.ts's and vipps.ts's own `WEBHOOK_EVENT_TYPE`-shaped
|
|
445
|
+
* maps), so this store never branches on `event.provider`. An event type
|
|
446
|
+
* absent from a map (e.g. a one-off `payment.*` event with no agreement, or
|
|
447
|
+
* `unknown`) is simply not applied. */
|
|
448
|
+
const CHARGE_STATUS_FROM_EVENT = {
|
|
449
|
+
'payment.created': 'pending',
|
|
450
|
+
'payment.authorized': 'authorized',
|
|
451
|
+
'payment.captured': 'captured',
|
|
452
|
+
'payment.refunded': 'refunded',
|
|
453
|
+
'payment.cancelled': 'canceled',
|
|
454
|
+
'payment.failed': 'failed',
|
|
455
|
+
'payment.expired': 'failed',
|
|
456
|
+
'charge.reserved': 'pending',
|
|
457
|
+
'charge.captured': 'captured',
|
|
458
|
+
'charge.canceled': 'canceled',
|
|
459
|
+
'charge.refunded': 'refunded',
|
|
460
|
+
'charge.failed': 'failed',
|
|
461
|
+
};
|
|
462
|
+
const AGREEMENT_STATUS_FROM_EVENT = {
|
|
463
|
+
'agreement.activated': 'active',
|
|
464
|
+
'agreement.rejected': 'stopped',
|
|
465
|
+
'agreement.stopped': 'stopped',
|
|
466
|
+
'agreement.expired': 'expired',
|
|
467
|
+
};
|
|
468
|
+
/**
|
|
469
|
+
* Applies one normalized webhook event to whichever row it is about,
|
|
470
|
+
* idempotently and monotonically: a delivery retried with the same event
|
|
471
|
+
* body updates nothing the second time (`changed: false`), and an event
|
|
472
|
+
* whose implied status is not a forward transition from what is currently
|
|
473
|
+
* stored is refused as stale (`changed: false, ignored: 'stale'`) rather
|
|
474
|
+
* than applied -- see `CHARGE_ALLOWED_PREDECESSORS`'s doc comment for why a
|
|
475
|
+
* webhook transport's redelivery-without-ordering guarantee makes this
|
|
476
|
+
* necessary, not merely defensive. A caller may safely act on every
|
|
477
|
+
* `changed: true` result (e.g. a captured charge triggering an invoice)
|
|
478
|
+
* without its own separate de-duplication or sequencing, including under
|
|
479
|
+
* two concurrent deliveries of the same event (`transitionCharge`'s doc
|
|
480
|
+
* comment).
|
|
481
|
+
*
|
|
482
|
+
* Charge events are checked before agreement events: `event.paymentReference`
|
|
483
|
+
* (a charge id) is more specific than `event.agreementReference`, and the
|
|
484
|
+
* two are mutually exclusive per event in practice (an event about a charge
|
|
485
|
+
* always carries a payment/charge id; an event about the agreement alone
|
|
486
|
+
* never does -- see each adapter's webhook parsing).
|
|
487
|
+
*/
|
|
488
|
+
export async function applyWebhookEvent(db, event) {
|
|
489
|
+
const chargeStatus = CHARGE_STATUS_FROM_EVENT[event.type];
|
|
490
|
+
if (chargeStatus && event.paymentReference) {
|
|
491
|
+
const outcome = await transitionCharge(db, event.provider, event.paymentReference, chargeStatus);
|
|
492
|
+
if (outcome.kind === 'applied') {
|
|
493
|
+
return {
|
|
494
|
+
matched: 'charge',
|
|
495
|
+
changed: outcome.changed,
|
|
496
|
+
charge: chargeFromRow(outcome.row),
|
|
497
|
+
previousStatus: outcome.previousStatus,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
if (outcome.kind === 'stale') {
|
|
501
|
+
return {
|
|
502
|
+
matched: 'charge',
|
|
503
|
+
changed: false,
|
|
504
|
+
ignored: 'stale',
|
|
505
|
+
charge: chargeFromRow(outcome.row),
|
|
506
|
+
previousStatus: outcome.row.status,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
// 'not_found': this provider_charge_id names no charge this store
|
|
510
|
+
// tracks -- fall through in case it is instead an agreement event.
|
|
511
|
+
}
|
|
512
|
+
const agreementStatus = AGREEMENT_STATUS_FROM_EVENT[event.type];
|
|
513
|
+
if (agreementStatus && event.agreementReference) {
|
|
514
|
+
const outcome = await transitionAgreement(db, event.provider, event.agreementReference, agreementStatus);
|
|
515
|
+
if (outcome.kind === 'applied') {
|
|
516
|
+
return {
|
|
517
|
+
matched: 'agreement',
|
|
518
|
+
changed: outcome.changed,
|
|
519
|
+
agreement: agreementFromRow(outcome.row),
|
|
520
|
+
previousStatus: outcome.previousStatus,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
if (outcome.kind === 'stale') {
|
|
524
|
+
return {
|
|
525
|
+
matched: 'agreement',
|
|
526
|
+
changed: false,
|
|
527
|
+
ignored: 'stale',
|
|
528
|
+
agreement: agreementFromRow(outcome.row),
|
|
529
|
+
previousStatus: outcome.row.status,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return { matched: 'none', changed: false };
|
|
534
|
+
}
|
package/dist/stripe.js
CHANGED
|
@@ -202,6 +202,16 @@ export function createStripeProvider(options) {
|
|
|
202
202
|
}, input.idempotencyKey);
|
|
203
203
|
return toPaymentResult(pi);
|
|
204
204
|
},
|
|
205
|
+
async getRecurringAgreement(agreementReference) {
|
|
206
|
+
const si = await get(`/setup_intents/${agreementReference}`);
|
|
207
|
+
return {
|
|
208
|
+
provider: 'stripe',
|
|
209
|
+
agreementReference: si.id,
|
|
210
|
+
status: SETUP_INTENT_STATUS[si.status] ?? 'pending',
|
|
211
|
+
clientSecret: si.client_secret,
|
|
212
|
+
raw: si,
|
|
213
|
+
};
|
|
214
|
+
},
|
|
205
215
|
};
|
|
206
216
|
}
|
|
207
217
|
const WEBHOOK_EVENT_TYPE = {
|
|
@@ -213,6 +223,13 @@ const WEBHOOK_EVENT_TYPE = {
|
|
|
213
223
|
'payment_intent.payment_failed': 'payment.failed',
|
|
214
224
|
'charge.refunded': 'payment.refunded',
|
|
215
225
|
'charge.refund.updated': 'payment.refunded',
|
|
226
|
+
// A SetupIntent is this adapter's recurring agreement (module doc
|
|
227
|
+
// comment): its own lifecycle events normalize to the same `agreement.*`
|
|
228
|
+
// names Vipps' Recurring API webhooks use, so `store.ts`'s
|
|
229
|
+
// `applyWebhookEvent` never branches on provider.
|
|
230
|
+
'setup_intent.succeeded': 'agreement.activated',
|
|
231
|
+
'setup_intent.canceled': 'agreement.stopped',
|
|
232
|
+
'setup_intent.setup_failed': 'agreement.rejected',
|
|
216
233
|
};
|
|
217
234
|
/**
|
|
218
235
|
* Verifies a Stripe webhook's `Stripe-Signature` header against the raw
|
|
@@ -267,7 +284,13 @@ export function verifyStripeWebhook(rawBody, signatureHeader, webhookSecret, opt
|
|
|
267
284
|
throw new WebhookVerificationError('stripe', 'payload is not valid JSON');
|
|
268
285
|
}
|
|
269
286
|
const object = event.data?.object;
|
|
270
|
-
|
|
287
|
+
// A `setup_intent.*` event is about the agreement itself, never a
|
|
288
|
+
// payment -- its object has neither a payment_intent nor anything this
|
|
289
|
+
// package would call a paymentReference. See `getRecurringAgreement`'s
|
|
290
|
+
// module doc comment and `WEBHOOK_EVENT_TYPE`'s `setup_intent.*` entries.
|
|
291
|
+
const isSetupIntentEvent = (event.type ?? '').startsWith('setup_intent.');
|
|
292
|
+
const paymentReference = isSetupIntentEvent ? '' : (object?.payment_intent ?? object?.id ?? '');
|
|
293
|
+
const agreementReference = isSetupIntentEvent ? object?.id : undefined;
|
|
271
294
|
const amount = typeof object?.amount === 'number' && object.currency
|
|
272
295
|
? { value: object.amount, currency: object.currency.toUpperCase() }
|
|
273
296
|
: undefined;
|
|
@@ -276,6 +299,7 @@ export function verifyStripeWebhook(rawBody, signatureHeader, webhookSecret, opt
|
|
|
276
299
|
type: WEBHOOK_EVENT_TYPE[event.type ?? ''] ?? 'unknown',
|
|
277
300
|
eventId: event.id ?? '',
|
|
278
301
|
paymentReference,
|
|
302
|
+
agreementReference,
|
|
279
303
|
amount,
|
|
280
304
|
raw: event,
|
|
281
305
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -116,15 +116,31 @@ export interface ChargeRecurringAgreementInput {
|
|
|
116
116
|
}
|
|
117
117
|
/** A closed set, plus `unknown`. A provider event type this package does not
|
|
118
118
|
* yet recognise normalizes to `unknown` rather than throwing, so a new
|
|
119
|
-
* Stripe or Vipps event is forward-compatible, not a crash.
|
|
120
|
-
|
|
119
|
+
* Stripe or Vipps event is forward-compatible, not a crash.
|
|
120
|
+
*
|
|
121
|
+
* `payment.*` is a one-off ePayment/PaymentIntent event (`createPayment`,
|
|
122
|
+
* `capturePayment`, `refundPayment`). `agreement.*` and `charge.*` are the
|
|
123
|
+
* recurring-agreement lifecycle `store.ts`'s `applyWebhookEvent` acts on --
|
|
124
|
+
* Vipps' own Recurring API webhook catalogue (`recurring.agreement-*.v1`,
|
|
125
|
+
* `recurring.charge-*.v1`); Stripe's SetupIntent events are normalized into
|
|
126
|
+
* the same `agreement.*` names (`succeeded`->`activated`,
|
|
127
|
+
* `canceled`->`stopped`, `setup_failed`->`rejected`) and its off-session
|
|
128
|
+
* PaymentIntent charge into the same `charge.*` names, so a caller of
|
|
129
|
+
* `applyWebhookEvent` never branches on provider. */
|
|
130
|
+
export type NormalizedWebhookEventType = 'payment.created' | 'payment.authorized' | 'payment.captured' | 'payment.refunded' | 'payment.cancelled' | 'payment.failed' | 'payment.expired' | 'agreement.activated' | 'agreement.rejected' | 'agreement.stopped' | 'agreement.expired' | 'charge.reserved' | 'charge.captured' | 'charge.canceled' | 'charge.refunded' | 'charge.failed' | 'unknown';
|
|
121
131
|
export interface NormalizedWebhookEvent {
|
|
122
132
|
readonly provider: 'stripe' | 'vipps';
|
|
123
133
|
readonly type: NormalizedWebhookEventType;
|
|
124
134
|
/** The provider's own event id, for de-duplicating retried deliveries. */
|
|
125
135
|
readonly eventId: string;
|
|
126
|
-
/** Matches `PaymentResult.providerReference`
|
|
136
|
+
/** Matches `PaymentResult.providerReference` -- the one-off payment or
|
|
137
|
+
* recurring charge this event is about. Empty string for an
|
|
138
|
+
* `agreement.*` event, which is about the agreement alone. */
|
|
127
139
|
readonly paymentReference: string;
|
|
140
|
+
/** Matches `RecurringAgreementResult.agreementReference`. Present on every
|
|
141
|
+
* `agreement.*` and `charge.*` event; absent on a one-off `payment.*` event,
|
|
142
|
+
* which has no agreement. */
|
|
143
|
+
readonly agreementReference?: string;
|
|
128
144
|
readonly amount?: Money;
|
|
129
145
|
/** The provider's own event payload, for anything this shape does not carry. */
|
|
130
146
|
readonly raw: unknown;
|
package/dist/vipps.d.ts
CHANGED
|
@@ -6,14 +6,37 @@ export interface VippsProviderOptions {
|
|
|
6
6
|
readonly subscriptionKey: string;
|
|
7
7
|
/** `Merchant-Serial-Number`: the sales unit's MSN. */
|
|
8
8
|
readonly merchantSerialNumber: string;
|
|
9
|
-
/**
|
|
10
|
-
*
|
|
11
|
-
* never
|
|
12
|
-
*
|
|
13
|
-
|
|
9
|
+
/**
|
|
10
|
+
* A valid OAuth access token, already fetched. Given this, the adapter
|
|
11
|
+
* never calls `POST /accesstoken/get` itself and never refreshes it --
|
|
12
|
+
* the caller must, on its own schedule. This is v1's shape (0.2.x and
|
|
13
|
+
* earlier), kept so an existing caller migrates by adding
|
|
14
|
+
* `clientId`/`clientSecret` on its own timeline rather than in lockstep
|
|
15
|
+
* with a version bump.
|
|
16
|
+
*
|
|
17
|
+
* Exactly one of `accessToken` or `clientId`+`clientSecret` must be given.
|
|
18
|
+
*/
|
|
19
|
+
readonly accessToken?: string;
|
|
20
|
+
/** The sales unit's OAuth client id, for this adapter to fetch and cache
|
|
21
|
+
* its own access token. Requires `clientSecret`. See
|
|
22
|
+
* docs/adr/0007-vipps-access-token-caching.md. */
|
|
23
|
+
readonly clientId?: string;
|
|
24
|
+
/** The sales unit's OAuth client secret. Requires `clientId`. */
|
|
25
|
+
readonly clientSecret?: string;
|
|
26
|
+
/** Seconds of safety margin before a cached token's real expiry at which
|
|
27
|
+
* this adapter fetches a new one instead of reusing it, so a request that
|
|
28
|
+
* starts just under the deadline does not race the token's own expiry.
|
|
29
|
+
* Default: 60. */
|
|
30
|
+
readonly tokenRefreshMarginSeconds?: number;
|
|
14
31
|
readonly fetch?: FetchLike;
|
|
15
32
|
/** Override for testing. Defaults to `https://api.vipps.no`. */
|
|
16
33
|
readonly baseUrl?: string;
|
|
34
|
+
/** Override for testing. Defaults to `https://api.vipps.no/accesstoken/get`. */
|
|
35
|
+
readonly tokenUrl?: string;
|
|
36
|
+
/** Aborts any request (including the token fetch) still pending after
|
|
37
|
+
* this many milliseconds, via `AbortSignal.timeout`. Default: 10 000
|
|
38
|
+
* (10s). A provider that never responds must not hang its caller forever. */
|
|
39
|
+
readonly timeoutMs?: number;
|
|
17
40
|
}
|
|
18
41
|
export declare function createVippsProvider(options: VippsProviderOptions): PaymentProvider;
|
|
19
42
|
export interface VippsWebhookHeaders {
|