@wtfalch/payments 0.3.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 CHANGED
@@ -42,6 +42,7 @@ const payment = await stripe.createPayment({
42
42
  ```ts
43
43
  await stripe.capturePayment(payment.providerReference); // full capture
44
44
  await stripe.refundPayment(payment.providerReference, { reason: 'requested_by_customer' });
45
+ await stripe.cancelPayment(payment.providerReference); // a reserved, uncaptured payment
45
46
  ```
46
47
 
47
48
  ### Recurring agreements
@@ -68,12 +69,32 @@ await vipps.chargeRecurringAgreement(agreement.agreementReference, {
68
69
  // the payer approves it, or on a schedule) -- what refreshAgreementStatus
69
70
  // below calls under the hood.
70
71
  await vipps.getRecurringAgreement(agreement.agreementReference);
72
+
73
+ // Stop it (e.g. Archon cancelling a company's subscription) and change its
74
+ // price (a plan change). A caller also tracking this agreement in the store
75
+ // below should follow stopRecurringAgreement with refreshAgreementStatus --
76
+ // or let the resulting agreement.stopped webhook reach applyWebhookEvent --
77
+ // to persist the transition; these two calls only reach the provider.
78
+ await vipps.stopRecurringAgreement(agreement.agreementReference);
79
+ await vipps.updateRecurringAgreement(agreement.agreementReference, {
80
+ amount: { value: 39900, currency: 'NOK' },
81
+ });
82
+
83
+ // Cancel a charge that was created but never settles (e.g. its period was
84
+ // credited before it captured).
85
+ await vipps.cancelRecurringCharge(agreement.agreementReference, chargeReference);
71
86
  ```
72
87
 
73
88
  For Stripe, `createRecurringAgreement` creates a SetupIntent
74
89
  (`agreement.clientSecret` -- confirm with Stripe.js) and
75
90
  `chargeRecurringAgreement` looks up the payment method it saved and charges
76
91
  it off-session. `getRecurringAgreement` reads that same SetupIntent back.
92
+ `stopRecurringAgreement` cancels that SetupIntent (there is no Subscription
93
+ object here to cancel); `updateRecurringAgreement` keeps its
94
+ `metadata[amount]` in sync, since a SetupIntent carries no real price of its
95
+ own; `cancelRecurringCharge` cancels the PaymentIntent that
96
+ `chargeRecurringAgreement` created (Stripe has no separate "recurring
97
+ charge" resource).
77
98
 
78
99
  ### Store
79
100
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { CapturePaymentInput, ChargeRecurringAgreementInput, CreatePaymentInput, CreateRecurringAgreementInput, Money, NormalizedWebhookEvent, NormalizedWebhookEventType, PaymentResult, PaymentStatus, RecurringAgreementResult, RecurringAgreementStatus, RefundInput, RefundResult, } from './types.js';
1
+ export type { CancelPaymentInput, CancelRecurringChargeInput, CapturePaymentInput, ChargeRecurringAgreementInput, CreatePaymentInput, CreateRecurringAgreementInput, Money, NormalizedWebhookEvent, NormalizedWebhookEventType, PaymentResult, PaymentStatus, RecurringAgreementResult, RecurringAgreementStatus, RefundInput, RefundResult, StopRecurringAgreementInput, UpdateRecurringAgreementInput, } from './types.js';
2
2
  export { PaymentProviderError, WebhookVerificationError } from './types.js';
3
3
  export type { PaymentProvider } from './provider.js';
4
4
  export type { FetchInit, FetchLike, FetchResponseLike } from './http.js';
@@ -1,4 +1,4 @@
1
- import type { CapturePaymentInput, ChargeRecurringAgreementInput, CreatePaymentInput, CreateRecurringAgreementInput, PaymentResult, RecurringAgreementResult, RefundInput, RefundResult } from './types.js';
1
+ import type { CancelPaymentInput, CancelRecurringChargeInput, CapturePaymentInput, ChargeRecurringAgreementInput, CreatePaymentInput, CreateRecurringAgreementInput, PaymentResult, RecurringAgreementResult, RefundInput, RefundResult, StopRecurringAgreementInput, UpdateRecurringAgreementInput } from './types.js';
2
2
  /**
3
3
  * One shape, two adapters (`createStripeProvider`, `createVippsProvider`).
4
4
  * Webhook verification is deliberately not a method here: it needs no
@@ -12,6 +12,13 @@ export interface PaymentProvider {
12
12
  createPayment(input: CreatePaymentInput): Promise<PaymentResult>;
13
13
  capturePayment(providerReference: string, input?: CapturePaymentInput): Promise<PaymentResult>;
14
14
  refundPayment(providerReference: string, input?: RefundInput): Promise<RefundResult>;
15
+ /** Cancels a reserved, uncaptured payment (Vipps: `POST
16
+ * /epayment/v1/payments/{reference}/cancel`; Stripe: `POST
17
+ * /payment_intents/{id}/cancel`) -- a payment this store has not yet
18
+ * captured, left otherwise for the payer to see until it expires. Throws
19
+ * `PaymentProviderError` if the payment is not in a cancelable state
20
+ * (already captured, refunded, or already canceled). */
21
+ cancelPayment(providerReference: string, input?: CancelPaymentInput): Promise<PaymentResult>;
15
22
  createRecurringAgreement(input: CreateRecurringAgreementInput): Promise<RecurringAgreementResult>;
16
23
  chargeRecurringAgreement(agreementReference: string, input: ChargeRecurringAgreementInput): Promise<PaymentResult>;
17
24
  /** The agreement's current status, read fresh from the provider -- what
@@ -19,4 +26,32 @@ export interface PaymentProvider {
19
26
  * since neither adapter's create call itself learns of a payer's later
20
27
  * approval/rejection (that arrives by webhook, or by polling this). */
21
28
  getRecurringAgreement(agreementReference: string): Promise<RecurringAgreementResult>;
29
+ /** Stops the agreement (Vipps: `PATCH /recurring/v3/agreements/{id}`,
30
+ * `status: STOPPED`, idempotent -- stopping an already-stopped agreement
31
+ * is a no-op; Stripe: cancelling the SetupIntent that backs this
32
+ * adapter's agreement, `POST /setup_intents/{id}/cancel`). A caller that
33
+ * also tracks this agreement in `store.ts` should follow this with
34
+ * `refreshAgreementStatus` (or let the resulting webhook --
35
+ * `agreement.stopped` -- reach `applyWebhookEvent`) to persist the
36
+ * transition; this call only reaches the provider. */
37
+ stopRecurringAgreement(agreementReference: string, input?: StopRecurringAgreementInput): Promise<RecurringAgreementResult>;
38
+ /** Changes the agreement's price (Vipps: the same `PATCH
39
+ * .../agreements/{id}` as `stopRecurringAgreement`, with `pricing.amount`;
40
+ * Stripe: updates the SetupIntent's own `metadata[amount]`, kept in sync
41
+ * for symmetry with Vipps -- see `UpdateRecurringAgreementInput`'s doc
42
+ * comment). Required so a later `chargeRecurringAgreement` above the
43
+ * agreement's old amount is not refused by the provider. */
44
+ updateRecurringAgreement(agreementReference: string, input: UpdateRecurringAgreementInput): Promise<RecurringAgreementResult>;
45
+ /** Cancels a pending/reserved recurring charge that has not yet been
46
+ * captured -- e.g. one created for a billing period that is then credited
47
+ * before it settles (Vipps: `DELETE
48
+ * /recurring/v3/agreements/{agreementId}/charges/{chargeId}`, permitted
49
+ * for a PENDING/DUE/RESERVED charge; Stripe: the charge is a PaymentIntent
50
+ * -- see `chargeRecurringAgreement`'s doc comment -- so this is the same
51
+ * PaymentIntent cancel `cancelPayment` makes). Neither vendor's response
52
+ * gives a body worth normalizing (Vipps: 202/204, no body; Stripe's body
53
+ * is discarded for symmetry) -- the caller learns the resulting status the
54
+ * same way `chargeRecurringAgreement`'s caller already does, via a webhook
55
+ * (`charge.canceled` / `payment.cancelled`) reaching `applyWebhookEvent`. */
56
+ cancelRecurringCharge(agreementReference: string, chargeReference: string, input?: CancelRecurringChargeInput): Promise<void>;
22
57
  }
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
@@ -212,6 +234,64 @@ export function createStripeProvider(options) {
212
234
  raw: si,
213
235
  };
214
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
+ },
215
295
  };
216
296
  }
217
297
  const WEBHOOK_EVENT_TYPE = {
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,6 +117,24 @@ 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
140
  * Stripe or Vipps event is forward-compatible, not a crash.
@@ -152,9 +173,18 @@ export declare class PaymentProviderError extends Error {
152
173
  readonly provider: 'stripe' | 'vipps';
153
174
  readonly status?: number;
154
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;
155
184
  constructor(provider: 'stripe' | 'vipps', message: string, options?: {
156
185
  status?: number;
157
186
  raw?: unknown;
187
+ code?: string;
158
188
  });
159
189
  }
160
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.js CHANGED
@@ -13,6 +13,12 @@ import { PaymentProviderError, WebhookVerificationError, } from './types.js';
13
13
  * - https://developer.vippsmobilepay.com/docs/knowledge-base/across-borders/ (Nordic markets)
14
14
  * - https://developer.vippsmobilepay.com/docs/APIs/recurring-api/recurring-api-guide/
15
15
  *
16
+ * Re-fetched 2026-09-28 (still primary docs, no live calls) for
17
+ * `stopRecurringAgreement`/`updateRecurringAgreement` (`PATCH
18
+ * /recurring/v3/agreements/{id}`), `cancelRecurringCharge` (`DELETE
19
+ * /recurring/v3/agreements/{id}/charges/{chargeId}`) and `cancelPayment`
20
+ * (`POST /epayment/v1/payments/{reference}/cancel`) -- same two URLs above.
21
+ *
16
22
  * One Recurring/ePayment API serves all three Nordic markets (see
17
23
  * docs/adr/0006-payment-store.md's "MobilePay" section and this package's
18
24
  * README, "MobilePay (Denmark, Finland)"): same base URL, same headers, same
@@ -209,6 +215,68 @@ export function createVippsProvider(options) {
209
215
  }
210
216
  return parsed;
211
217
  }
218
+ /** Reads any error body a non-2xx response carries, but never throws on a
219
+ * body that fails to parse (used by `patch`/`del` below, whose *success*
220
+ * responses are documented as empty -- see each call site). */
221
+ async function readErrorBody(response) {
222
+ try {
223
+ return await response.json();
224
+ }
225
+ catch {
226
+ return undefined;
227
+ }
228
+ }
229
+ /** `PATCH /recurring/v3/agreements/{id}` (`stopRecurringAgreement`,
230
+ * `updateRecurringAgreement`) documents a `204 No Content` success
231
+ * response -- https://developer.vippsmobilepay.com/api/recurring/
232
+ * (fetched 2026-09-28) -- so this never calls `response.json()` on the
233
+ * success path, unlike `post`/`get` above. */
234
+ async function patch(path, body, idempotencyKey) {
235
+ const response = await fetchImpl(`${baseUrl}${path}`, {
236
+ method: 'PATCH',
237
+ headers: await resolveHeaders(idempotencyKey),
238
+ body: JSON.stringify(body),
239
+ signal: AbortSignal.timeout(timeoutMs),
240
+ });
241
+ if (!response.ok) {
242
+ throw new PaymentProviderError('vipps', `HTTP ${response.status}`, {
243
+ status: response.status,
244
+ raw: await readErrorBody(response),
245
+ });
246
+ }
247
+ }
248
+ /** `DELETE /recurring/v3/agreements/{id}/charges/{chargeId}`
249
+ * (`cancelRecurringCharge`) documents a `202 Accepted`/`204 No Content`
250
+ * success response with no body -- same source and fetch date as `patch`
251
+ * above. */
252
+ async function del(path, idempotencyKey) {
253
+ const response = await fetchImpl(`${baseUrl}${path}`, {
254
+ method: 'DELETE',
255
+ headers: await resolveHeaders(idempotencyKey),
256
+ signal: AbortSignal.timeout(timeoutMs),
257
+ });
258
+ if (!response.ok) {
259
+ throw new PaymentProviderError('vipps', `HTTP ${response.status}`, {
260
+ status: response.status,
261
+ raw: await readErrorBody(response),
262
+ });
263
+ }
264
+ }
265
+ /** Shared by `getRecurringAgreement` and the two `PATCH .../agreements`
266
+ * calls (`stopRecurringAgreement`, `updateRecurringAgreement`), neither of
267
+ * which gets its resulting status back in its own (empty) response body --
268
+ * both re-read the agreement the same way `getRecurringAgreement` always
269
+ * has. */
270
+ async function fetchAgreement(agreementReference) {
271
+ const agreement = await get(`/recurring/v3/agreements/${agreementReference}`);
272
+ return {
273
+ provider: 'vipps',
274
+ agreementReference: agreement.id,
275
+ status: (agreement.status ? AGREEMENT_STATUS[agreement.status] : undefined) ?? 'pending',
276
+ confirmationUrl: agreement.vippsConfirmationUrl,
277
+ raw: agreement,
278
+ };
279
+ }
212
280
  return {
213
281
  name: 'vipps',
214
282
  async createPayment(input) {
@@ -273,6 +341,39 @@ export function createVippsProvider(options) {
273
341
  raw: payment,
274
342
  };
275
343
  },
344
+ async cancelPayment(providerReference, input = {}) {
345
+ // POST /epayment/v1/payments/{reference}/cancel --
346
+ // https://developer.vippsmobilepay.com/api/epayment/ (fetched
347
+ // 2026-09-28). Cancels a reserved, uncaptured payment; Vipps does not
348
+ // document an Idempotency-Key requirement here (unlike capture/refund),
349
+ // but this adapter still sends one, consistent with every other
350
+ // mutating call in this package.
351
+ const payment = await post(`/epayment/v1/payments/${providerReference}/cancel`, {}, input.idempotencyKey ?? `cnc:${providerReference}`);
352
+ // Same integrity check as capturePayment/refundPayment.
353
+ if (payment.reference !== providerReference) {
354
+ throw new PaymentProviderError('vipps', `cancel response reference "${payment.reference}" does not match the requested payment "${providerReference}"`, { raw: payment });
355
+ }
356
+ // The cancel response does not always carry an amount (neither
357
+ // `amount` nor `aggregate.authorizedAmount`) -- when it does not,
358
+ // re-read the payment rather than assume Vipps left it as-is. If the
359
+ // re-read also has no amount, a caller must never read a made-up
360
+ // amount as if Vipps reported it: a defaulted 0/NOK is simply wrong
361
+ // for a EUR or DKK merchant. Throw instead, with a distinct `code` so
362
+ // a caller can tell "cancelled, amount unknown, re-read later" apart
363
+ // from a plain cancel failure.
364
+ const resolved = (payment.amount ?? payment.aggregate?.authorizedAmount)
365
+ ? payment
366
+ : await get(`/epayment/v1/payments/${providerReference}`);
367
+ const knownAmount = resolved.amount ?? resolved.aggregate?.authorizedAmount;
368
+ if (!knownAmount) {
369
+ throw new PaymentProviderError('vipps', `cancel response for payment ${providerReference} carries no amount to report, even after re-reading the payment`, { raw: resolved, code: 'cancel_amount_unknown' });
370
+ }
371
+ const fallback = {
372
+ value: knownAmount.value,
373
+ currency: knownAmount.currency.toUpperCase(),
374
+ };
375
+ return toPaymentResult(resolved, fallback);
376
+ },
276
377
  async createRecurringAgreement(input) {
277
378
  // pricing.type LEGACY (fixed amount) vs VARIABLE (suggestedMaxAmount,
278
379
  // no fixed amount): https://developer.vippsmobilepay.com/api/recurring/
@@ -335,14 +436,72 @@ export function createVippsProvider(options) {
335
436
  };
336
437
  },
337
438
  async getRecurringAgreement(agreementReference) {
338
- const agreement = await get(`/recurring/v3/agreements/${agreementReference}`);
339
- return {
340
- provider: 'vipps',
341
- agreementReference: agreement.agreementId,
342
- status: (agreement.status ? AGREEMENT_STATUS[agreement.status] : undefined) ?? 'pending',
343
- confirmationUrl: agreement.vippsConfirmationUrl,
344
- raw: agreement,
345
- };
439
+ return fetchAgreement(agreementReference);
440
+ },
441
+ async stopRecurringAgreement(agreementReference, input = {}) {
442
+ // PATCH /recurring/v3/agreements/{id}, { status: 'STOPPED' } --
443
+ // https://developer.vippsmobilepay.com/api/recurring/ (fetched
444
+ // 2026-09-28). Idempotent on Vipps' side: stopping an already-STOPPED
445
+ // agreement returns 204 with no further effect. The response has no
446
+ // body, so this reads the resulting status back the same way
447
+ // `getRecurringAgreement` always has.
448
+ await patch(`/recurring/v3/agreements/${agreementReference}`, { status: 'STOPPED' }, input.idempotencyKey ?? `stop:${agreementReference}`);
449
+ return fetchAgreement(agreementReference);
450
+ },
451
+ async updateRecurringAgreement(agreementReference, input) {
452
+ // Same PATCH endpoint as stopRecurringAgreement. The update body's
453
+ // `pricing` is `UpdateAgreementPricingRequest` in Vipps' own Recurring
454
+ // v3 OpenAPI spec (recurring-swagger-id.yaml, "Recurring Payments
455
+ // Merchant API" 3.2.3, fetched 2026-09-29 from
456
+ // https://developer.vippsmobilepay.com/redocusaurus/recurring-swagger-id.yaml,
457
+ // linked from https://developer.vippsmobilepay.com/api/recurring/):
458
+ //
459
+ // PricingUpdateRequest:
460
+ // title: UpdateAgreementPricingRequest
461
+ // type: object
462
+ // properties:
463
+ // amount: { type: integer, ... }
464
+ // suggestedMaxAmount: { type: integer, ... }
465
+ //
466
+ // No `currency`, no `type` -- unlike `PricingRequestV3` (create), which
467
+ // requires both. An agreement's currency is fixed at creation and
468
+ // cannot be changed through this endpoint, so this checks the
469
+ // agreement's current currency (from a GET, `pricing.currency` on
470
+ // `AgreementResponseV3`) before sending anything, and refuses a
471
+ // currency change with a clear `PaymentProviderError` instead of
472
+ // sending a field Vipps' spec does not list and getting back a 400.
473
+ // This is the LEGACY (fixed-price) `amount` field; a VARIABLE
474
+ // agreement's `suggestedMaxAmount` cap is a separate, payer-driven
475
+ // value this method does not touch -- see
476
+ // `UpdateRecurringAgreementInput`'s doc comment. Vipps itself refuses
477
+ // (400) a price change on a stopped agreement; this adapter does not
478
+ // duplicate that check. It does refuse anything but a LEGACY
479
+ // agreement below, because Vipps applies `pricing.amount` only to a
480
+ // LEGACY one -- a PATCH against a VARIABLE or FLEXIBLE agreement
481
+ // returns 204 and changes nothing, which would otherwise read to this
482
+ // method's caller as a silent no-op success.
483
+ const nextCurrency = input.amount.currency.toUpperCase();
484
+ const current = await get(`/recurring/v3/agreements/${agreementReference}`);
485
+ if (current.pricing?.type !== 'LEGACY') {
486
+ throw new PaymentProviderError('vipps', `agreement ${agreementReference} has pricing.type "${current.pricing?.type ?? 'unknown'}"; updateRecurringAgreement's amount can only be updated on a LEGACY agreement`, { raw: current });
487
+ }
488
+ const currentCurrency = current.pricing?.currency?.toUpperCase();
489
+ if (currentCurrency && currentCurrency !== nextCurrency) {
490
+ throw new PaymentProviderError('vipps', `agreement ${agreementReference} is priced in ${currentCurrency}; updateRecurringAgreement cannot change its currency to ${nextCurrency} (Vipps’ update-agreement pricing body takes amount only)`, { raw: current });
491
+ }
492
+ await patch(`/recurring/v3/agreements/${agreementReference}`, { pricing: { amount: input.amount.value } }, input.idempotencyKey ?? `upd:${agreementReference}:${input.amount.value}:${nextCurrency}`);
493
+ return fetchAgreement(agreementReference);
494
+ },
495
+ async cancelRecurringCharge(agreementReference, chargeReference, input = {}) {
496
+ // DELETE /recurring/v3/agreements/{agreementId}/charges/{chargeId} --
497
+ // https://developer.vippsmobilepay.com/api/recurring/ (fetched
498
+ // 2026-09-28). Permitted for a PENDING/DUE/RESERVED charge; a
499
+ // PARTIALLY_CAPTURED charge has its remaining funds released back to
500
+ // the payer. Returns 202/204 with no body -- the caller learns the
501
+ // outcome via the resulting `recurring.charge-canceled.v1` webhook
502
+ // (`charge.canceled`, already wired to `applyWebhookEvent`), the same
503
+ // way a created charge's own outcome already arrives.
504
+ await del(`/recurring/v3/agreements/${agreementReference}/charges/${chargeReference}`, input.idempotencyKey ?? `dch:${chargeReference}`);
346
505
  },
347
506
  };
348
507
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/payments",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "One PaymentProvider interface -- create a payment, capture, refund, set up and charge a recurring agreement, verify a webhook -- over Stripe and Vipps MobilePay, plus a provider-neutral Postgres store for agreements and charges. Holds no card data.",
5
5
  "repository": {
6
6
  "type": "git",