@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/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
- const paymentReference = object?.payment_intent ?? object?.id ?? '';
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
- export type NormalizedWebhookEventType = 'payment.created' | 'payment.authorized' | 'payment.captured' | 'payment.refunded' | 'payment.cancelled' | 'payment.failed' | 'payment.expired' | 'unknown';
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` / the payment this event is about. */
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
- /** A valid OAuth access token. The caller obtains and refreshes this
10
- * (`POST /accesstoken/get`); this package never does, the same as it
11
- * never resolves a Stripe secret key on its own -- see the module doc
12
- * comment. */
13
- readonly accessToken: string;
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 {