@wtfalch/payments 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -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 +5 -1
- 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 +41 -1
- package/dist/store.d.ts +181 -0
- package/dist/store.js +534 -0
- package/dist/stripe.js +105 -1
- package/dist/types.d.ts +49 -3
- package/dist/types.js +9 -0
- package/dist/vipps.d.ts +28 -5
- package/dist/vipps.js +313 -15
- package/package.json +13 -13
package/dist/stripe.js
CHANGED
|
@@ -10,6 +10,16 @@ import { PaymentProviderError, WebhookVerificationError, } from './types.js';
|
|
|
10
10
|
* - https://docs.stripe.com/api/refunds/create
|
|
11
11
|
* - https://docs.stripe.com/api/setup_intents
|
|
12
12
|
* - https://docs.stripe.com/webhooks (manual signature verification)
|
|
13
|
+
*
|
|
14
|
+
* Re-fetched 2026-09-28 (still primary docs, no live calls) for
|
|
15
|
+
* `stopRecurringAgreement`/`cancelPayment`/`cancelRecurringCharge`
|
|
16
|
+
* (https://docs.stripe.com/api/setup_intents/cancel,
|
|
17
|
+
* https://docs.stripe.com/api/payment_intents/cancel) and
|
|
18
|
+
* `updateRecurringAgreement` (https://docs.stripe.com/api/setup_intents/update).
|
|
19
|
+
* This adapter's "recurring agreement" is a SetupIntent, not a Subscription
|
|
20
|
+
* (see `createRecurringAgreement`'s doc comment below) -- there is no
|
|
21
|
+
* Subscription object anywhere in this file, so its cancel/update endpoints
|
|
22
|
+
* are never the ones used here.
|
|
13
23
|
*/
|
|
14
24
|
import { hmacSha256Hex, safeEqual } from './webhook-crypto.js';
|
|
15
25
|
/** Form-urlencodes Stripe's `/v1` request bodies, including one level of
|
|
@@ -159,6 +169,18 @@ export function createStripeProvider(options) {
|
|
|
159
169
|
raw: refund,
|
|
160
170
|
};
|
|
161
171
|
},
|
|
172
|
+
async cancelPayment(providerReference, input = {}) {
|
|
173
|
+
// https://docs.stripe.com/api/payment_intents/cancel (fetched
|
|
174
|
+
// 2026-09-28). Cancelable from requires_payment_method,
|
|
175
|
+
// requires_capture, requires_confirmation, requires_action, or (rarely)
|
|
176
|
+
// processing -- Stripe itself refuses any other status.
|
|
177
|
+
const pi = await call(`/payment_intents/${providerReference}/cancel`, {}, input.idempotencyKey ?? `cnc:${providerReference}`);
|
|
178
|
+
// Same integrity check as capturePayment/refundPayment.
|
|
179
|
+
if (pi.id !== providerReference) {
|
|
180
|
+
throw new PaymentProviderError('stripe', `cancel response id "${pi.id}" does not match the requested payment "${providerReference}"`, { raw: pi });
|
|
181
|
+
}
|
|
182
|
+
return toPaymentResult(pi);
|
|
183
|
+
},
|
|
162
184
|
async createRecurringAgreement(input) {
|
|
163
185
|
// Vipps-only: a SetupIntent has no pricing type or cap to declare up
|
|
164
186
|
// front -- it only saves a payment method, and the amount for each
|
|
@@ -202,6 +224,74 @@ export function createStripeProvider(options) {
|
|
|
202
224
|
}, input.idempotencyKey);
|
|
203
225
|
return toPaymentResult(pi);
|
|
204
226
|
},
|
|
227
|
+
async getRecurringAgreement(agreementReference) {
|
|
228
|
+
const si = await get(`/setup_intents/${agreementReference}`);
|
|
229
|
+
return {
|
|
230
|
+
provider: 'stripe',
|
|
231
|
+
agreementReference: si.id,
|
|
232
|
+
status: SETUP_INTENT_STATUS[si.status] ?? 'pending',
|
|
233
|
+
clientSecret: si.client_secret,
|
|
234
|
+
raw: si,
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
async stopRecurringAgreement(agreementReference, input = {}) {
|
|
238
|
+
// https://docs.stripe.com/api/setup_intents/cancel (fetched
|
|
239
|
+
// 2026-09-28). This adapter's "recurring agreement" is a SetupIntent,
|
|
240
|
+
// not a Subscription (module doc comment) -- stopping it is cancelling
|
|
241
|
+
// that SetupIntent, the same transition `WEBHOOK_EVENT_TYPE`'s
|
|
242
|
+
// `setup_intent.canceled` -> `agreement.stopped` mapping already
|
|
243
|
+
// assumes. Cancelable only from requires_payment_method,
|
|
244
|
+
// requires_confirmation or requires_action; Stripe refuses any other
|
|
245
|
+
// status (e.g. an already-`succeeded` SetupIntent) with an error this
|
|
246
|
+
// call surfaces as `PaymentProviderError`.
|
|
247
|
+
const si = await call(`/setup_intents/${agreementReference}/cancel`, {}, input.idempotencyKey ?? `stop:${agreementReference}`);
|
|
248
|
+
if (si.id !== agreementReference) {
|
|
249
|
+
throw new PaymentProviderError('stripe', `cancel response id "${si.id}" does not match the requested agreement "${agreementReference}"`, { raw: si });
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
provider: 'stripe',
|
|
253
|
+
agreementReference: si.id,
|
|
254
|
+
status: SETUP_INTENT_STATUS[si.status] ?? 'pending',
|
|
255
|
+
clientSecret: si.client_secret,
|
|
256
|
+
raw: si,
|
|
257
|
+
};
|
|
258
|
+
},
|
|
259
|
+
async updateRecurringAgreement(agreementReference, input) {
|
|
260
|
+
// https://docs.stripe.com/api/setup_intents/update (fetched
|
|
261
|
+
// 2026-09-28). A SetupIntent has no price of its own to change (module
|
|
262
|
+
// doc comment) -- this keeps `metadata[amount]`/`metadata[currency]`
|
|
263
|
+
// (set at `createRecurringAgreement` time, for symmetry with Vipps) in
|
|
264
|
+
// sync with a caller's own plan/price change; `chargeRecurringAgreement`
|
|
265
|
+
// always reads its charge amount from its own caller, never from here,
|
|
266
|
+
// so this has no effect on what a later charge actually bills.
|
|
267
|
+
const si = await call(`/setup_intents/${agreementReference}`, {
|
|
268
|
+
'metadata[amount]': input.amount.value,
|
|
269
|
+
'metadata[currency]': input.amount.currency.toUpperCase(),
|
|
270
|
+
}, input.idempotencyKey ??
|
|
271
|
+
`upd:${agreementReference}:${input.amount.value}:${input.amount.currency.toUpperCase()}`);
|
|
272
|
+
if (si.id !== agreementReference) {
|
|
273
|
+
throw new PaymentProviderError('stripe', `update response id "${si.id}" does not match the requested agreement "${agreementReference}"`, { raw: si });
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
provider: 'stripe',
|
|
277
|
+
agreementReference: si.id,
|
|
278
|
+
status: SETUP_INTENT_STATUS[si.status] ?? 'pending',
|
|
279
|
+
clientSecret: si.client_secret,
|
|
280
|
+
raw: si,
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
async cancelRecurringCharge(_agreementReference, chargeReference, input = {}) {
|
|
284
|
+
// A recurring charge is a PaymentIntent here (`chargeRecurringAgreement`'s
|
|
285
|
+
// doc comment) -- Stripe has no separate "recurring charge" resource
|
|
286
|
+
// the way Vipps' Recurring API does, so cancelling one is the same
|
|
287
|
+
// PaymentIntent cancel call `cancelPayment` makes.
|
|
288
|
+
// https://docs.stripe.com/api/payment_intents/cancel (fetched
|
|
289
|
+
// 2026-09-28).
|
|
290
|
+
const pi = await call(`/payment_intents/${chargeReference}/cancel`, {}, input.idempotencyKey ?? `dch:${chargeReference}`);
|
|
291
|
+
if (pi.id !== chargeReference) {
|
|
292
|
+
throw new PaymentProviderError('stripe', `cancel response id "${pi.id}" does not match the requested charge "${chargeReference}"`, { raw: pi });
|
|
293
|
+
}
|
|
294
|
+
},
|
|
205
295
|
};
|
|
206
296
|
}
|
|
207
297
|
const WEBHOOK_EVENT_TYPE = {
|
|
@@ -213,6 +303,13 @@ const WEBHOOK_EVENT_TYPE = {
|
|
|
213
303
|
'payment_intent.payment_failed': 'payment.failed',
|
|
214
304
|
'charge.refunded': 'payment.refunded',
|
|
215
305
|
'charge.refund.updated': 'payment.refunded',
|
|
306
|
+
// A SetupIntent is this adapter's recurring agreement (module doc
|
|
307
|
+
// comment): its own lifecycle events normalize to the same `agreement.*`
|
|
308
|
+
// names Vipps' Recurring API webhooks use, so `store.ts`'s
|
|
309
|
+
// `applyWebhookEvent` never branches on provider.
|
|
310
|
+
'setup_intent.succeeded': 'agreement.activated',
|
|
311
|
+
'setup_intent.canceled': 'agreement.stopped',
|
|
312
|
+
'setup_intent.setup_failed': 'agreement.rejected',
|
|
216
313
|
};
|
|
217
314
|
/**
|
|
218
315
|
* Verifies a Stripe webhook's `Stripe-Signature` header against the raw
|
|
@@ -267,7 +364,13 @@ export function verifyStripeWebhook(rawBody, signatureHeader, webhookSecret, opt
|
|
|
267
364
|
throw new WebhookVerificationError('stripe', 'payload is not valid JSON');
|
|
268
365
|
}
|
|
269
366
|
const object = event.data?.object;
|
|
270
|
-
|
|
367
|
+
// A `setup_intent.*` event is about the agreement itself, never a
|
|
368
|
+
// payment -- its object has neither a payment_intent nor anything this
|
|
369
|
+
// package would call a paymentReference. See `getRecurringAgreement`'s
|
|
370
|
+
// module doc comment and `WEBHOOK_EVENT_TYPE`'s `setup_intent.*` entries.
|
|
371
|
+
const isSetupIntentEvent = (event.type ?? '').startsWith('setup_intent.');
|
|
372
|
+
const paymentReference = isSetupIntentEvent ? '' : (object?.payment_intent ?? object?.id ?? '');
|
|
373
|
+
const agreementReference = isSetupIntentEvent ? object?.id : undefined;
|
|
271
374
|
const amount = typeof object?.amount === 'number' && object.currency
|
|
272
375
|
? { value: object.amount, currency: object.currency.toUpperCase() }
|
|
273
376
|
: undefined;
|
|
@@ -276,6 +379,7 @@ export function verifyStripeWebhook(rawBody, signatureHeader, webhookSecret, opt
|
|
|
276
379
|
type: WEBHOOK_EVENT_TYPE[event.type ?? ''] ?? 'unknown',
|
|
277
380
|
eventId: event.id ?? '',
|
|
278
381
|
paymentReference,
|
|
382
|
+
agreementReference,
|
|
279
383
|
amount,
|
|
280
384
|
raw: event,
|
|
281
385
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -37,6 +37,9 @@ export interface RefundInput {
|
|
|
37
37
|
readonly reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer';
|
|
38
38
|
readonly idempotencyKey?: string;
|
|
39
39
|
}
|
|
40
|
+
export interface CancelPaymentInput {
|
|
41
|
+
readonly idempotencyKey?: string;
|
|
42
|
+
}
|
|
40
43
|
export interface PaymentResult {
|
|
41
44
|
readonly provider: 'stripe' | 'vipps';
|
|
42
45
|
/** The provider's own identifier for this payment (Stripe: PaymentIntent
|
|
@@ -114,17 +117,51 @@ export interface ChargeRecurringAgreementInput {
|
|
|
114
117
|
readonly dueDate?: string;
|
|
115
118
|
readonly idempotencyKey?: string;
|
|
116
119
|
}
|
|
120
|
+
export interface StopRecurringAgreementInput {
|
|
121
|
+
readonly idempotencyKey?: string;
|
|
122
|
+
}
|
|
123
|
+
/** A plan/price change to an existing agreement. Vipps: `PATCH
|
|
124
|
+
* /recurring/v3/agreements/{id}` with `pricing.amount` (the LEGACY fixed
|
|
125
|
+
* price) -- see `updateRecurringAgreement`'s doc comment on each adapter for
|
|
126
|
+
* why a VARIABLE or FLEXIBLE agreement's payer-driven cap is not covered by
|
|
127
|
+
* this input, and why Vipps refuses this update outright on either. Stripe:
|
|
128
|
+
* no price lives on a SetupIntent, so this keeps its
|
|
129
|
+
* `metadata[amount]`/`metadata[currency]` (set at `createRecurringAgreement`
|
|
130
|
+
* time, for symmetry with Vipps) in sync with a caller's own plan change. */
|
|
131
|
+
export interface UpdateRecurringAgreementInput {
|
|
132
|
+
readonly amount: Money;
|
|
133
|
+
readonly idempotencyKey?: string;
|
|
134
|
+
}
|
|
135
|
+
export interface CancelRecurringChargeInput {
|
|
136
|
+
readonly idempotencyKey?: string;
|
|
137
|
+
}
|
|
117
138
|
/** A closed set, plus `unknown`. A provider event type this package does not
|
|
118
139
|
* yet recognise normalizes to `unknown` rather than throwing, so a new
|
|
119
|
-
* Stripe or Vipps event is forward-compatible, not a crash.
|
|
120
|
-
|
|
140
|
+
* Stripe or Vipps event is forward-compatible, not a crash.
|
|
141
|
+
*
|
|
142
|
+
* `payment.*` is a one-off ePayment/PaymentIntent event (`createPayment`,
|
|
143
|
+
* `capturePayment`, `refundPayment`). `agreement.*` and `charge.*` are the
|
|
144
|
+
* recurring-agreement lifecycle `store.ts`'s `applyWebhookEvent` acts on --
|
|
145
|
+
* Vipps' own Recurring API webhook catalogue (`recurring.agreement-*.v1`,
|
|
146
|
+
* `recurring.charge-*.v1`); Stripe's SetupIntent events are normalized into
|
|
147
|
+
* the same `agreement.*` names (`succeeded`->`activated`,
|
|
148
|
+
* `canceled`->`stopped`, `setup_failed`->`rejected`) and its off-session
|
|
149
|
+
* PaymentIntent charge into the same `charge.*` names, so a caller of
|
|
150
|
+
* `applyWebhookEvent` never branches on provider. */
|
|
151
|
+
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
152
|
export interface NormalizedWebhookEvent {
|
|
122
153
|
readonly provider: 'stripe' | 'vipps';
|
|
123
154
|
readonly type: NormalizedWebhookEventType;
|
|
124
155
|
/** The provider's own event id, for de-duplicating retried deliveries. */
|
|
125
156
|
readonly eventId: string;
|
|
126
|
-
/** Matches `PaymentResult.providerReference`
|
|
157
|
+
/** Matches `PaymentResult.providerReference` -- the one-off payment or
|
|
158
|
+
* recurring charge this event is about. Empty string for an
|
|
159
|
+
* `agreement.*` event, which is about the agreement alone. */
|
|
127
160
|
readonly paymentReference: string;
|
|
161
|
+
/** Matches `RecurringAgreementResult.agreementReference`. Present on every
|
|
162
|
+
* `agreement.*` and `charge.*` event; absent on a one-off `payment.*` event,
|
|
163
|
+
* which has no agreement. */
|
|
164
|
+
readonly agreementReference?: string;
|
|
128
165
|
readonly amount?: Money;
|
|
129
166
|
/** The provider's own event payload, for anything this shape does not carry. */
|
|
130
167
|
readonly raw: unknown;
|
|
@@ -136,9 +173,18 @@ export declare class PaymentProviderError extends Error {
|
|
|
136
173
|
readonly provider: 'stripe' | 'vipps';
|
|
137
174
|
readonly status?: number;
|
|
138
175
|
readonly raw?: unknown;
|
|
176
|
+
/** A narrow, provider-adapter-defined tag for a failure a caller needs to
|
|
177
|
+
* distinguish from an ordinary provider-call failure -- e.g. Vipps'
|
|
178
|
+
* `cancelPayment` sets `'cancel_amount_unknown'` when a cancel went
|
|
179
|
+
* through but no amount could be read even after re-reading the payment,
|
|
180
|
+
* so a caller can tell that apart from "the cancel itself failed" instead
|
|
181
|
+
* of both looking like the same generic error. Absent on a plain HTTP/network
|
|
182
|
+
* failure. */
|
|
183
|
+
readonly code?: string;
|
|
139
184
|
constructor(provider: 'stripe' | 'vipps', message: string, options?: {
|
|
140
185
|
status?: number;
|
|
141
186
|
raw?: unknown;
|
|
187
|
+
code?: string;
|
|
142
188
|
});
|
|
143
189
|
}
|
|
144
190
|
/** A webhook payload failed to verify: missing/malformed signature header,
|
package/dist/types.js
CHANGED
|
@@ -10,12 +10,21 @@ export class PaymentProviderError extends Error {
|
|
|
10
10
|
provider;
|
|
11
11
|
status;
|
|
12
12
|
raw;
|
|
13
|
+
/** A narrow, provider-adapter-defined tag for a failure a caller needs to
|
|
14
|
+
* distinguish from an ordinary provider-call failure -- e.g. Vipps'
|
|
15
|
+
* `cancelPayment` sets `'cancel_amount_unknown'` when a cancel went
|
|
16
|
+
* through but no amount could be read even after re-reading the payment,
|
|
17
|
+
* so a caller can tell that apart from "the cancel itself failed" instead
|
|
18
|
+
* of both looking like the same generic error. Absent on a plain HTTP/network
|
|
19
|
+
* failure. */
|
|
20
|
+
code;
|
|
13
21
|
constructor(provider, message, options = {}) {
|
|
14
22
|
super(`payments(${provider}): ${message}`);
|
|
15
23
|
this.name = 'PaymentProviderError';
|
|
16
24
|
this.provider = provider;
|
|
17
25
|
this.status = options.status;
|
|
18
26
|
this.raw = options.raw;
|
|
27
|
+
this.code = options.code;
|
|
19
28
|
}
|
|
20
29
|
}
|
|
21
30
|
/** A webhook payload failed to verify: missing/malformed signature header,
|
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 {
|