@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/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
@@ -160,6 +160,13 @@ export function createStripeProvider(options) {
160
160
  };
161
161
  },
162
162
  async createRecurringAgreement(input) {
163
+ // Vipps-only: a SetupIntent has no pricing type or cap to declare up
164
+ // front -- it only saves a payment method, and the amount for each
165
+ // later charge is decided at `chargeRecurringAgreement` time. See the
166
+ // `variablePricing` doc comment on `CreateRecurringAgreementInput`.
167
+ if (input.variablePricing) {
168
+ throw new PaymentProviderError('stripe', 'variablePricing is not supported: a Stripe SetupIntent has no pricing type or cap to declare up front');
169
+ }
163
170
  const si = await call('/setup_intents', {
164
171
  usage: 'off_session',
165
172
  'metadata[reference]': input.reference,
@@ -195,6 +202,16 @@ export function createStripeProvider(options) {
195
202
  }, input.idempotencyKey);
196
203
  return toPaymentResult(pi);
197
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
+ },
198
215
  };
199
216
  }
200
217
  const WEBHOOK_EVENT_TYPE = {
@@ -206,6 +223,13 @@ const WEBHOOK_EVENT_TYPE = {
206
223
  'payment_intent.payment_failed': 'payment.failed',
207
224
  'charge.refunded': 'payment.refunded',
208
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',
209
233
  };
210
234
  /**
211
235
  * Verifies a Stripe webhook's `Stripe-Signature` header against the raw
@@ -260,7 +284,13 @@ export function verifyStripeWebhook(rawBody, signatureHeader, webhookSecret, opt
260
284
  throw new WebhookVerificationError('stripe', 'payload is not valid JSON');
261
285
  }
262
286
  const object = event.data?.object;
263
- const paymentReference = object?.payment_intent ?? object?.id ?? '';
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;
264
294
  const amount = typeof object?.amount === 'number' && object.currency
265
295
  ? { value: object.amount, currency: object.currency.toUpperCase() }
266
296
  : undefined;
@@ -269,6 +299,7 @@ export function verifyStripeWebhook(rawBody, signatureHeader, webhookSecret, opt
269
299
  type: WEBHOOK_EVENT_TYPE[event.type ?? ''] ?? 'unknown',
270
300
  eventId: event.id ?? '',
271
301
  paymentReference,
302
+ agreementReference,
272
303
  amount,
273
304
  raw: event,
274
305
  };
package/dist/types.d.ts CHANGED
@@ -64,7 +64,10 @@ export interface CreateRecurringAgreementInput {
64
64
  readonly reference: string;
65
65
  /** The periodic charge amount. Stripe's SetupIntent does not itself need
66
66
  * one, but it is required here for symmetry with Vipps and because a
67
- * caller building the agreement already knows it. */
67
+ * caller building the agreement already knows it. Under `variablePricing`,
68
+ * Vipps ignores this for the agreement itself (there is no fixed price to
69
+ * declare); it is still required, as an estimate for Stripe's metadata and
70
+ * for a caller that has not yet decided the actual per-charge amounts. */
68
71
  readonly amount: Money;
69
72
  readonly productName: string;
70
73
  /** Where the payer returns after approving the agreement. */
@@ -76,6 +79,21 @@ export interface CreateRecurringAgreementInput {
76
79
  readonly unit: 'day' | 'week' | 'month';
77
80
  readonly count: number;
78
81
  };
82
+ /** Vipps Recurring `pricing.type: 'VARIABLE'`: the agreement has no fixed
83
+ * price, only a `suggestedMaxAmount` shown to the payer when they approve
84
+ * it (the payer may accept a different max, and can change it later).
85
+ * Each `chargeRecurringAgreement` call then charges its own amount --
86
+ * e.g. a plan fee plus that month's metered usage -- as long as it is at
87
+ * or below the max the payer accepted; a charge above it is held `DUE`
88
+ * and fails if the payer has not raised their max by `due` + `retryDays`.
89
+ * Omit for the existing fixed-price ("LEGACY") behaviour, unchanged.
90
+ * Vipps-only: `createStripeProvider` throws `PaymentProviderError` if this
91
+ * is given, since a Stripe SetupIntent has no pricing type or cap to
92
+ * declare up front. Same currency as `amount`.
93
+ * https://developer.vippsmobilepay.com/api/recurring/ */
94
+ readonly variablePricing?: {
95
+ readonly suggestedMaxAmount: number;
96
+ };
79
97
  readonly idempotencyKey?: string;
80
98
  }
81
99
  export interface RecurringAgreementResult {
@@ -98,15 +116,31 @@ export interface ChargeRecurringAgreementInput {
98
116
  }
99
117
  /** A closed set, plus `unknown`. A provider event type this package does not
100
118
  * yet recognise normalizes to `unknown` rather than throwing, so a new
101
- * Stripe or Vipps event is forward-compatible, not a crash. */
102
- export type NormalizedWebhookEventType = 'payment.created' | 'payment.authorized' | 'payment.captured' | 'payment.refunded' | 'payment.cancelled' | 'payment.failed' | 'payment.expired' | 'unknown';
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';
103
131
  export interface NormalizedWebhookEvent {
104
132
  readonly provider: 'stripe' | 'vipps';
105
133
  readonly type: NormalizedWebhookEventType;
106
134
  /** The provider's own event id, for de-duplicating retried deliveries. */
107
135
  readonly eventId: string;
108
- /** Matches `PaymentResult.providerReference` / the payment this event is about. */
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. */
109
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;
110
144
  readonly amount?: Money;
111
145
  /** The provider's own event payload, for anything this shape does not carry. */
112
146
  readonly raw: unknown;