@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 +21 -0
- package/dist/index.d.ts +1 -1
- package/dist/provider.d.ts +36 -1
- package/dist/stripe.js +80 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.js +9 -0
- package/dist/vipps.js +167 -8
- package/package.json +1 -1
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';
|
package/dist/provider.d.ts
CHANGED
|
@@ -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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
+
"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",
|