@wtfalch/payments 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Tallis Falch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # @wtfalch/payments
2
+
3
+ One `PaymentProvider` interface -- create a payment, capture, refund, set up
4
+ and charge a recurring agreement -- over Stripe and Vipps MobilePay, plus a
5
+ `verify*Webhook` function per provider that turns a signed webhook request
6
+ into one normalized event shape. Holds no card data: plain HTTP to each
7
+ provider's REST API, zero runtime dependencies.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pnpm add @wtfalch/payments
13
+ ```
14
+
15
+ ## Use
16
+
17
+ Both adapters implement the same `PaymentProvider` interface. The caller
18
+ resolves each provider's credential (from `@wtfalch/keys`, in the estate's
19
+ case) and passes it in; this package never fetches or stores one.
20
+
21
+ ```ts
22
+ import { createStripeProvider, createVippsProvider } from '@wtfalch/payments';
23
+
24
+ const stripe = createStripeProvider({ secretKey }); // sk_...
25
+ const vipps = createVippsProvider({ subscriptionKey, merchantSerialNumber, accessToken });
26
+
27
+ const payment = await stripe.createPayment({
28
+ reference: 'order-123',
29
+ amount: { value: 1000, currency: 'NOK' }, // minor units: 1000 = 10.00 NOK
30
+ returnUrl: 'https://example.com/orders/123/return',
31
+ });
32
+ // payment.providerReference -- pass to capturePayment/refundPayment
33
+ // payment.raw.client_secret -- Stripe: confirm with Stripe.js/Elements
34
+ // payment.redirectUrl -- Vipps: send the payer here
35
+ ```
36
+
37
+ ```ts
38
+ await stripe.capturePayment(payment.providerReference); // full capture
39
+ await stripe.refundPayment(payment.providerReference, { reason: 'requested_by_customer' });
40
+ ```
41
+
42
+ ### Recurring agreements
43
+
44
+ Thin provider mechanics only -- prices, plans and dunning are
45
+ `@wtfalch/billing`'s job, not this package's:
46
+
47
+ ```ts
48
+ const agreement = await vipps.createRecurringAgreement({
49
+ reference: 'agreement-123',
50
+ amount: { value: 29900, currency: 'NOK' },
51
+ productName: 'Pro plan',
52
+ returnUrl: 'https://example.com/agreements/123/return',
53
+ managementUrl: 'https://example.com/account/subscription',
54
+ });
55
+ // agreement.confirmationUrl -- send the payer here to approve it
56
+
57
+ await vipps.chargeRecurringAgreement(agreement.agreementReference, {
58
+ amount: { value: 29900, currency: 'NOK' },
59
+ description: 'March invoice',
60
+ });
61
+ ```
62
+
63
+ For Stripe, `createRecurringAgreement` creates a SetupIntent
64
+ (`agreement.clientSecret` -- confirm with Stripe.js) and
65
+ `chargeRecurringAgreement` looks up the payment method it saved and charges
66
+ it off-session.
67
+
68
+ ### Webhooks
69
+
70
+ ```ts
71
+ import { verifyStripeWebhook, verifyVippsWebhook } from '@wtfalch/payments';
72
+
73
+ // Stripe: the raw request body and the Stripe-Signature header, unparsed.
74
+ const event = verifyStripeWebhook(rawBody, req.headers['stripe-signature'], webhookSecret);
75
+
76
+ // Vipps: the raw body plus the four headers the Azure-APIM HMAC scheme signs,
77
+ // and the request's own method + path (see the module doc comment in
78
+ // src/vipps.ts for the exact algorithm and its primary sources).
79
+ const event = verifyVippsWebhook(
80
+ rawBody,
81
+ { authorization: req.headers.authorization, xMsDate: req.headers['x-ms-date'], xMsContentSha256: req.headers['x-ms-content-sha256'], host: req.headers.host },
82
+ webhookSecret,
83
+ req.method,
84
+ req.url,
85
+ );
86
+ ```
87
+
88
+ Both throw `WebhookVerificationError` on a bad signature, a tampered
89
+ payload, or a timestamp outside the replay tolerance (default 5 minutes) --
90
+ never return a partially-trusted event. `event.type` is one of a closed set
91
+ (`payment.created | authorized | captured | refunded | cancelled | failed |
92
+ expired | unknown`); an event type this package does not yet recognise
93
+ normalizes to `unknown` rather than throwing, so a new provider event is
94
+ forward-compatible.
95
+
96
+ ## Errors
97
+
98
+ - `PaymentProviderError` -- a provider API call failed (HTTP error, network
99
+ error, or an unparsable response). Never thrown for "the payment was
100
+ declined"; that is a normal `PaymentResult`/`RefundResult` with a `failed`
101
+ status.
102
+ - `WebhookVerificationError` -- a webhook failed to verify. The caller must
103
+ reject the request, never act on the event.
104
+
105
+ ## Tests
106
+
107
+ From `packages/payments`:
108
+
109
+ ```sh
110
+ pnpm test
111
+ ```
112
+
113
+ Every test runs against recorded/fake fixtures (`src/fixtures/{stripe,vipps}`)
114
+ and a fake `fetch` (`src/test/fake-fetch.ts`) -- never a live provider call,
115
+ never a real key. Webhook verification tests build their own valid signature
116
+ independently of the code under test (see `*-webhook.test.ts`), then check
117
+ that a tampered payload, a tampered signature, a wrong secret, a wrong
118
+ request path (Vipps), and a stale timestamp are all rejected.
119
+
120
+ ## Known gap
121
+
122
+ The exact `state` enum on a Vipps ePayment response was not confirmed from
123
+ primary docs at build time (see `src/vipps.ts`'s module doc comment).
124
+ `derivePaymentStatus` falls back to the documented `aggregate.*Amount`
125
+ fields when `state` is absent or unrecognised, but this adapter has not been
126
+ exercised against a real Vipps sandbox response. Do that before relying on
127
+ it for anything that pays out money.
package/dist/http.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The minimal shape this package calls on `fetch`: a request in, a response
3
+ * with `ok`, `status`, `json()` and `text()` out. The real global `fetch`
4
+ * satisfies this structurally, and so does a fake with no network access,
5
+ * which is what every adapter test injects (never a real key, never a real
6
+ * call -- see each adapter's `*.test.ts`).
7
+ */
8
+ export type FetchLike = (input: string, init?: FetchInit) => Promise<FetchResponseLike>;
9
+ export interface FetchInit {
10
+ readonly method?: string;
11
+ readonly headers?: Readonly<Record<string, string>>;
12
+ readonly body?: string;
13
+ }
14
+ export interface FetchResponseLike {
15
+ readonly ok: boolean;
16
+ readonly status: number;
17
+ json(): Promise<unknown>;
18
+ text(): Promise<string>;
19
+ }
20
+ /** `globalThis.fetch`, present on Node >=18 and required by this package's
21
+ * `engines.node` (>=22). Read lazily so importing this module never fails in
22
+ * an environment without a global `fetch` unless a call is actually made. */
23
+ export declare function defaultFetch(): FetchLike;
package/dist/http.js ADDED
@@ -0,0 +1,9 @@
1
+ /** `globalThis.fetch`, present on Node >=18 and required by this package's
2
+ * `engines.node` (>=22). Read lazily so importing this module never fails in
3
+ * an environment without a global `fetch` unless a call is actually made. */
4
+ export function defaultFetch() {
5
+ if (typeof globalThis.fetch !== 'function') {
6
+ throw new Error('payments: no global fetch available; pass { fetch } explicitly to the provider constructor.');
7
+ }
8
+ return globalThis.fetch.bind(globalThis);
9
+ }
@@ -0,0 +1,8 @@
1
+ export type { CapturePaymentInput, ChargeRecurringAgreementInput, CreatePaymentInput, CreateRecurringAgreementInput, Money, NormalizedWebhookEvent, NormalizedWebhookEventType, PaymentResult, PaymentStatus, RecurringAgreementResult, RecurringAgreementStatus, RefundInput, RefundResult, } from './types.js';
2
+ export { PaymentProviderError, WebhookVerificationError } from './types.js';
3
+ export type { PaymentProvider } from './provider.js';
4
+ export type { FetchInit, FetchLike, FetchResponseLike } from './http.js';
5
+ export type { StripeProviderOptions, StripeWebhookOptions } from './stripe.js';
6
+ export { createStripeProvider, verifyStripeWebhook } from './stripe.js';
7
+ export type { VippsProviderOptions, VippsWebhookHeaders, VippsWebhookOptions, } from './vipps.js';
8
+ export { createVippsProvider, verifyVippsWebhook } from './vipps.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { PaymentProviderError, WebhookVerificationError } from './types.js';
2
+ export { createStripeProvider, verifyStripeWebhook } from './stripe.js';
3
+ export { createVippsProvider, verifyVippsWebhook } from './vipps.js';
@@ -0,0 +1,17 @@
1
+ import type { CapturePaymentInput, ChargeRecurringAgreementInput, CreatePaymentInput, CreateRecurringAgreementInput, PaymentResult, RecurringAgreementResult, RefundInput, RefundResult } from './types.js';
2
+ /**
3
+ * One shape, two adapters (`createStripeProvider`, `createVippsProvider`).
4
+ * Webhook verification is deliberately not a method here: it needs no
5
+ * provider instance (no credential is required to check a signature besides
6
+ * the webhook secret itself), so it is two standalone functions,
7
+ * `verifyStripeWebhook` and `verifyVippsWebhook`, each typed to that
8
+ * provider's own header shape rather than forced through one loose type.
9
+ */
10
+ export interface PaymentProvider {
11
+ readonly name: 'stripe' | 'vipps';
12
+ createPayment(input: CreatePaymentInput): Promise<PaymentResult>;
13
+ capturePayment(providerReference: string, input?: CapturePaymentInput): Promise<PaymentResult>;
14
+ refundPayment(providerReference: string, input?: RefundInput): Promise<RefundResult>;
15
+ createRecurringAgreement(input: CreateRecurringAgreementInput): Promise<RecurringAgreementResult>;
16
+ chargeRecurringAgreement(agreementReference: string, input: ChargeRecurringAgreementInput): Promise<PaymentResult>;
17
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { type FetchLike } from './http.js';
2
+ import type { PaymentProvider } from './provider.js';
3
+ import { type NormalizedWebhookEvent } from './types.js';
4
+ export interface StripeProviderOptions {
5
+ /** The Stripe secret key (`sk_...` or a restricted key). The caller
6
+ * resolves this (from `@wtfalch/keys`, in the estate's case); this
7
+ * package never fetches or stores it beyond the call it is passed to. */
8
+ readonly secretKey: string;
9
+ /** Alternative to the real network for tests. Defaults to `globalThis.fetch`. */
10
+ readonly fetch?: FetchLike;
11
+ /** Override for testing. Defaults to `https://api.stripe.com/v1`. */
12
+ readonly baseUrl?: string;
13
+ }
14
+ /**
15
+ * A Stripe adapter. `createPayment` creates a PaymentIntent for the caller's
16
+ * frontend to confirm with Stripe.js/Elements using `clientSecret`
17
+ * (`raw.client_secret`); this package never touches card data itself.
18
+ */
19
+ export declare function createStripeProvider(options: StripeProviderOptions): PaymentProvider;
20
+ export interface StripeWebhookOptions {
21
+ /** Seconds of clock skew to tolerate between the event's timestamp and
22
+ * now, guarding against replay of an old, otherwise-valid signature.
23
+ * Default matches Stripe's own official-library default: 300 (5 minutes). */
24
+ readonly toleranceSeconds?: number;
25
+ /** Injectable for tests. Defaults to `Date.now`. */
26
+ readonly now?: () => number;
27
+ }
28
+ /**
29
+ * Verifies a Stripe webhook's `Stripe-Signature` header against the raw
30
+ * request body, by hand -- the manual algorithm from
31
+ * https://docs.stripe.com/webhooks#verify-manually (no `stripe` SDK):
32
+ *
33
+ * 1. Parse `t=` (timestamp) and `v1=` (signature) out of the header.
34
+ * 2. Build `signedPayload = "${t}.${rawBody}"`.
35
+ * 3. HMAC-SHA256(webhookSecret, signedPayload), hex-encoded.
36
+ * 4. Constant-time compare to `v1`; reject if the timestamp is outside
37
+ * `toleranceSeconds` of now (replay protection).
38
+ *
39
+ * `rawBody` must be the exact, unparsed request body -- Stripe's own
40
+ * warning applies here too: any framework reformatting of the body before
41
+ * this call breaks verification.
42
+ */
43
+ export declare function verifyStripeWebhook(rawBody: string, signatureHeader: string, webhookSecret: string, options?: StripeWebhookOptions): NormalizedWebhookEvent;
package/dist/stripe.js ADDED
@@ -0,0 +1,275 @@
1
+ import { defaultFetch } from './http.js';
2
+ import { PaymentProviderError, WebhookVerificationError, } from './types.js';
3
+ /**
4
+ * The Stripe adapter. Plain HTTP against Stripe's REST API (`/v1/*`,
5
+ * `application/x-www-form-urlencoded`, bearer auth with the secret key) --
6
+ * no `stripe` npm package. See docs/adr/0003-plain-http-no-provider-sdks.md.
7
+ *
8
+ * Sources (fetched 2026-09-23, primary docs, no live calls):
9
+ * - https://docs.stripe.com/api/payment_intents
10
+ * - https://docs.stripe.com/api/refunds/create
11
+ * - https://docs.stripe.com/api/setup_intents
12
+ * - https://docs.stripe.com/webhooks (manual signature verification)
13
+ */
14
+ import { hmacSha256Hex, safeEqual } from './webhook-crypto.js';
15
+ /** Form-urlencodes Stripe's `/v1` request bodies, including one level of
16
+ * bracket-notation nesting (`metadata[key]=value`) -- the only nesting this
17
+ * adapter sends. */
18
+ function toFormBody(params) {
19
+ const pairs = [];
20
+ for (const [key, value] of Object.entries(params)) {
21
+ if (value === undefined)
22
+ continue;
23
+ // Stripe's own examples send bracket-notation keys with the brackets
24
+ // literal (`metadata[order_id]=...`), not percent-encoded -- only the
25
+ // key's other characters and the value are encoded.
26
+ const encodedKey = encodeURIComponent(key).replace(/%5B/g, '[').replace(/%5D/g, ']');
27
+ pairs.push(`${encodedKey}=${encodeURIComponent(String(value))}`);
28
+ }
29
+ return pairs.join('&');
30
+ }
31
+ function metadataParams(metadata) {
32
+ const params = {};
33
+ if (!metadata)
34
+ return params;
35
+ for (const [key, value] of Object.entries(metadata)) {
36
+ params[`metadata[${key}]`] = value;
37
+ }
38
+ return params;
39
+ }
40
+ const PAYMENT_INTENT_STATUS = {
41
+ requires_payment_method: 'pending',
42
+ requires_confirmation: 'pending',
43
+ processing: 'pending',
44
+ requires_action: 'requires_action',
45
+ requires_capture: 'authorized',
46
+ succeeded: 'captured',
47
+ canceled: 'canceled',
48
+ };
49
+ const SETUP_INTENT_STATUS = {
50
+ requires_payment_method: 'pending',
51
+ requires_confirmation: 'pending',
52
+ requires_action: 'pending',
53
+ processing: 'pending',
54
+ succeeded: 'active',
55
+ canceled: 'stopped',
56
+ };
57
+ const REFUND_STATUS = {
58
+ pending: 'pending',
59
+ requires_action: 'pending',
60
+ succeeded: 'succeeded',
61
+ failed: 'failed',
62
+ canceled: 'failed',
63
+ };
64
+ function toPaymentResult(pi) {
65
+ return {
66
+ provider: 'stripe',
67
+ providerReference: pi.id,
68
+ status: PAYMENT_INTENT_STATUS[pi.status] ?? 'pending',
69
+ amount: { value: pi.amount, currency: pi.currency.toUpperCase() },
70
+ redirectUrl: pi.next_action?.redirect_to_url?.url,
71
+ raw: pi,
72
+ };
73
+ }
74
+ /**
75
+ * A Stripe adapter. `createPayment` creates a PaymentIntent for the caller's
76
+ * frontend to confirm with Stripe.js/Elements using `clientSecret`
77
+ * (`raw.client_secret`); this package never touches card data itself.
78
+ */
79
+ export function createStripeProvider(options) {
80
+ const fetchImpl = options.fetch ?? defaultFetch();
81
+ const baseUrl = options.baseUrl ?? 'https://api.stripe.com/v1';
82
+ async function call(path, params, idempotencyKey) {
83
+ const headers = {
84
+ Authorization: `Bearer ${options.secretKey}`,
85
+ 'Content-Type': 'application/x-www-form-urlencoded',
86
+ };
87
+ if (idempotencyKey)
88
+ headers['Idempotency-Key'] = idempotencyKey;
89
+ const response = await fetchImpl(`${baseUrl}${path}`, {
90
+ method: 'POST',
91
+ headers,
92
+ body: toFormBody(params),
93
+ });
94
+ const body = await response.json();
95
+ if (!response.ok) {
96
+ const message = typeof body === 'object' && body !== null && 'error' in body
97
+ ? String(body.error?.message ?? response.status)
98
+ : `HTTP ${response.status}`;
99
+ throw new PaymentProviderError('stripe', message, { status: response.status, raw: body });
100
+ }
101
+ return body;
102
+ }
103
+ async function get(path) {
104
+ const response = await fetchImpl(`${baseUrl}${path}`, {
105
+ method: 'GET',
106
+ headers: { Authorization: `Bearer ${options.secretKey}` },
107
+ });
108
+ const body = await response.json();
109
+ if (!response.ok) {
110
+ throw new PaymentProviderError('stripe', `HTTP ${response.status}`, {
111
+ status: response.status,
112
+ raw: body,
113
+ });
114
+ }
115
+ return body;
116
+ }
117
+ return {
118
+ name: 'stripe',
119
+ async createPayment(input) {
120
+ const pi = await call('/payment_intents', {
121
+ amount: input.amount.value,
122
+ currency: input.amount.currency.toLowerCase(),
123
+ description: input.description,
124
+ 'automatic_payment_methods[enabled]': true,
125
+ 'metadata[reference]': input.reference,
126
+ ...metadataParams(input.metadata),
127
+ }, input.idempotencyKey);
128
+ return toPaymentResult(pi);
129
+ },
130
+ async capturePayment(providerReference, input = {}) {
131
+ const pi = await call(`/payment_intents/${providerReference}/capture`, { amount_to_capture: input.amount?.value }, input.idempotencyKey);
132
+ // Defence in depth: the response is trusted as Stripe's, but a result
133
+ // that names a *different* PaymentIntent than the one just captured
134
+ // must never be silently returned as if it were this call's result --
135
+ // caught here rather than only up at the caller, which has no way to
136
+ // know this response was ever supposed to be about `providerReference`.
137
+ if (pi.id !== providerReference) {
138
+ throw new PaymentProviderError('stripe', `capture response id "${pi.id}" does not match the requested payment "${providerReference}"`, { raw: pi });
139
+ }
140
+ return toPaymentResult(pi);
141
+ },
142
+ async refundPayment(providerReference, input = {}) {
143
+ const refund = await call('/refunds', {
144
+ payment_intent: providerReference,
145
+ amount: input.amount?.value,
146
+ reason: input.reason,
147
+ }, input.idempotencyKey);
148
+ // Same integrity check as capturePayment: never attribute a refund to
149
+ // a payment_intent other than the one this call actually requested.
150
+ if (refund.payment_intent !== providerReference) {
151
+ throw new PaymentProviderError('stripe', `refund response payment_intent "${refund.payment_intent}" does not match the requested payment "${providerReference}"`, { raw: refund });
152
+ }
153
+ return {
154
+ provider: 'stripe',
155
+ refundReference: refund.id,
156
+ paymentReference: refund.payment_intent,
157
+ status: REFUND_STATUS[refund.status] ?? 'pending',
158
+ amount: { value: refund.amount, currency: refund.currency.toUpperCase() },
159
+ raw: refund,
160
+ };
161
+ },
162
+ async createRecurringAgreement(input) {
163
+ const si = await call('/setup_intents', {
164
+ usage: 'off_session',
165
+ 'metadata[reference]': input.reference,
166
+ 'metadata[productName]': input.productName,
167
+ 'metadata[amount]': input.amount.value,
168
+ 'metadata[currency]': input.amount.currency.toUpperCase(),
169
+ }, input.idempotencyKey);
170
+ return {
171
+ provider: 'stripe',
172
+ agreementReference: si.id,
173
+ status: SETUP_INTENT_STATUS[si.status] ?? 'pending',
174
+ clientSecret: si.client_secret,
175
+ raw: si,
176
+ };
177
+ },
178
+ async chargeRecurringAgreement(agreementReference, input) {
179
+ // A SetupIntent only saves a payment method; charging it off-session
180
+ // is a separate PaymentIntent against the customer + payment method
181
+ // it saved. Two calls, not one -- Stripe has no single "charge this
182
+ // agreement" endpoint the way Vipps does.
183
+ const si = await get(`/setup_intents/${agreementReference}`);
184
+ if (!si.customer || !si.payment_method) {
185
+ throw new PaymentProviderError('stripe', `setup_intent ${agreementReference} has no saved customer/payment_method yet -- the payer has not completed it`, { raw: si });
186
+ }
187
+ const pi = await call('/payment_intents', {
188
+ amount: input.amount.value,
189
+ currency: input.amount.currency.toLowerCase(),
190
+ customer: si.customer,
191
+ payment_method: si.payment_method,
192
+ off_session: true,
193
+ confirm: true,
194
+ description: input.description,
195
+ }, input.idempotencyKey);
196
+ return toPaymentResult(pi);
197
+ },
198
+ };
199
+ }
200
+ const WEBHOOK_EVENT_TYPE = {
201
+ 'payment_intent.created': 'payment.created',
202
+ 'payment_intent.requires_action': 'payment.authorized',
203
+ 'payment_intent.amount_capturable_updated': 'payment.authorized',
204
+ 'payment_intent.succeeded': 'payment.captured',
205
+ 'payment_intent.canceled': 'payment.cancelled',
206
+ 'payment_intent.payment_failed': 'payment.failed',
207
+ 'charge.refunded': 'payment.refunded',
208
+ 'charge.refund.updated': 'payment.refunded',
209
+ };
210
+ /**
211
+ * Verifies a Stripe webhook's `Stripe-Signature` header against the raw
212
+ * request body, by hand -- the manual algorithm from
213
+ * https://docs.stripe.com/webhooks#verify-manually (no `stripe` SDK):
214
+ *
215
+ * 1. Parse `t=` (timestamp) and `v1=` (signature) out of the header.
216
+ * 2. Build `signedPayload = "${t}.${rawBody}"`.
217
+ * 3. HMAC-SHA256(webhookSecret, signedPayload), hex-encoded.
218
+ * 4. Constant-time compare to `v1`; reject if the timestamp is outside
219
+ * `toleranceSeconds` of now (replay protection).
220
+ *
221
+ * `rawBody` must be the exact, unparsed request body -- Stripe's own
222
+ * warning applies here too: any framework reformatting of the body before
223
+ * this call breaks verification.
224
+ */
225
+ export function verifyStripeWebhook(rawBody, signatureHeader, webhookSecret, options = {}) {
226
+ const toleranceSeconds = options.toleranceSeconds ?? 300;
227
+ const now = options.now ?? Date.now;
228
+ const parts = signatureHeader.split(',').map((part) => part.trim());
229
+ let timestamp;
230
+ const v1Signatures = [];
231
+ for (const part of parts) {
232
+ const eq = part.indexOf('=');
233
+ if (eq === -1)
234
+ continue;
235
+ const key = part.slice(0, eq);
236
+ const value = part.slice(eq + 1);
237
+ if (key === 't')
238
+ timestamp = value;
239
+ else if (key === 'v1')
240
+ v1Signatures.push(value);
241
+ }
242
+ if (!timestamp || v1Signatures.length === 0) {
243
+ throw new WebhookVerificationError('stripe', 'Stripe-Signature header is missing "t=" or "v1="');
244
+ }
245
+ const signedPayload = `${timestamp}.${rawBody}`;
246
+ const expected = hmacSha256Hex(webhookSecret, signedPayload);
247
+ const matches = v1Signatures.some((sig) => safeEqual(sig, expected));
248
+ if (!matches) {
249
+ throw new WebhookVerificationError('stripe', 'signature does not match the payload');
250
+ }
251
+ const ageSeconds = Math.abs(now() / 1000 - Number(timestamp));
252
+ if (!Number.isFinite(ageSeconds) || ageSeconds > toleranceSeconds) {
253
+ throw new WebhookVerificationError('stripe', `timestamp is ${Math.round(ageSeconds)}s old, outside the ${toleranceSeconds}s tolerance (possible replay)`);
254
+ }
255
+ let event;
256
+ try {
257
+ event = JSON.parse(rawBody);
258
+ }
259
+ catch {
260
+ throw new WebhookVerificationError('stripe', 'payload is not valid JSON');
261
+ }
262
+ const object = event.data?.object;
263
+ const paymentReference = object?.payment_intent ?? object?.id ?? '';
264
+ const amount = typeof object?.amount === 'number' && object.currency
265
+ ? { value: object.amount, currency: object.currency.toUpperCase() }
266
+ : undefined;
267
+ return {
268
+ provider: 'stripe',
269
+ type: WEBHOOK_EVENT_TYPE[event.type ?? ''] ?? 'unknown',
270
+ eventId: event.id ?? '',
271
+ paymentReference,
272
+ amount,
273
+ raw: event,
274
+ };
275
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Shared vocabulary for both adapters. Nothing here is provider-specific;
3
+ * `stripe.ts` and `vipps.ts` each translate their provider's own wire shape
4
+ * into these types and back.
5
+ */
6
+ /** An amount in the smallest unit of its currency (cents, øre, ...). Both
7
+ * Stripe and Vipps already speak this way on the wire, so there is no
8
+ * conversion to get wrong at the boundary. */
9
+ export interface Money {
10
+ /** Integer, minor units. Never a float. */
11
+ readonly value: number;
12
+ /** ISO 4217, upper case, e.g. "NOK", "EUR", "USD". */
13
+ readonly currency: string;
14
+ }
15
+ export type PaymentStatus = 'pending' | 'requires_action' | 'authorized' | 'captured' | 'partially_refunded' | 'refunded' | 'canceled' | 'failed';
16
+ export type RecurringAgreementStatus = 'pending' | 'active' | 'stopped' | 'expired';
17
+ export interface CreatePaymentInput {
18
+ /** The merchant's own reference for this payment. Must be unique per attempt. */
19
+ readonly reference: string;
20
+ readonly amount: Money;
21
+ readonly description?: string;
22
+ /** Where the payer returns to after completing the payment. */
23
+ readonly returnUrl: string;
24
+ /** Passed through to the provider as its idempotency key, so a retried
25
+ * `createPayment` call with the same key never double-charges. */
26
+ readonly idempotencyKey?: string;
27
+ readonly metadata?: Readonly<Record<string, string>>;
28
+ }
29
+ export interface CapturePaymentInput {
30
+ /** Omit for a full capture of whatever was authorized. */
31
+ readonly amount?: Money;
32
+ readonly idempotencyKey?: string;
33
+ }
34
+ export interface RefundInput {
35
+ /** Omit for a full refund of the remaining captured amount. */
36
+ readonly amount?: Money;
37
+ readonly reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer';
38
+ readonly idempotencyKey?: string;
39
+ }
40
+ export interface PaymentResult {
41
+ readonly provider: 'stripe' | 'vipps';
42
+ /** The provider's own identifier for this payment (Stripe: PaymentIntent
43
+ * id; Vipps: the `reference` echoed back). Pass this to `capturePayment`,
44
+ * `refundPayment`, and to match a webhook event to the payment it is about. */
45
+ readonly providerReference: string;
46
+ readonly status: PaymentStatus;
47
+ readonly amount: Money;
48
+ /** Where to send the payer to complete the payment, when the provider
49
+ * needs a redirect (Vipps always; Stripe only for some payment methods). */
50
+ readonly redirectUrl?: string;
51
+ /** The provider's own response body, for anything this shape does not
52
+ * carry. Never relied on by this package itself. */
53
+ readonly raw: unknown;
54
+ }
55
+ export interface RefundResult {
56
+ readonly provider: 'stripe' | 'vipps';
57
+ readonly refundReference: string;
58
+ readonly paymentReference: string;
59
+ readonly status: 'pending' | 'succeeded' | 'failed';
60
+ readonly amount: Money;
61
+ readonly raw: unknown;
62
+ }
63
+ export interface CreateRecurringAgreementInput {
64
+ readonly reference: string;
65
+ /** The periodic charge amount. Stripe's SetupIntent does not itself need
66
+ * one, but it is required here for symmetry with Vipps and because a
67
+ * caller building the agreement already knows it. */
68
+ readonly amount: Money;
69
+ readonly productName: string;
70
+ /** Where the payer returns after approving the agreement. */
71
+ readonly returnUrl: string;
72
+ /** Vipps' `merchantAgreementUrl`: the page where the payer manages this
73
+ * agreement. Required by Vipps for Norwegian merchants; ignored by Stripe. */
74
+ readonly managementUrl?: string;
75
+ readonly interval?: {
76
+ readonly unit: 'day' | 'week' | 'month';
77
+ readonly count: number;
78
+ };
79
+ readonly idempotencyKey?: string;
80
+ }
81
+ export interface RecurringAgreementResult {
82
+ readonly provider: 'stripe' | 'vipps';
83
+ readonly agreementReference: string;
84
+ readonly status: RecurringAgreementStatus;
85
+ /** Where the payer approves the agreement (Vipps: `vippsConfirmationUrl`). */
86
+ readonly confirmationUrl?: string;
87
+ /** Stripe's SetupIntent client secret, for the caller's frontend to
88
+ * confirm with Stripe.js. Absent for Vipps. */
89
+ readonly clientSecret?: string;
90
+ readonly raw: unknown;
91
+ }
92
+ export interface ChargeRecurringAgreementInput {
93
+ readonly amount: Money;
94
+ readonly description: string;
95
+ /** ISO date (YYYY-MM-DD). Vipps requires this at least one day ahead; Stripe ignores it. */
96
+ readonly dueDate?: string;
97
+ readonly idempotencyKey?: string;
98
+ }
99
+ /** A closed set, plus `unknown`. A provider event type this package does not
100
+ * yet recognise normalizes to `unknown` rather than throwing, so a new
101
+ * Stripe or Vipps event is forward-compatible, not a crash. */
102
+ export type NormalizedWebhookEventType = 'payment.created' | 'payment.authorized' | 'payment.captured' | 'payment.refunded' | 'payment.cancelled' | 'payment.failed' | 'payment.expired' | 'unknown';
103
+ export interface NormalizedWebhookEvent {
104
+ readonly provider: 'stripe' | 'vipps';
105
+ readonly type: NormalizedWebhookEventType;
106
+ /** The provider's own event id, for de-duplicating retried deliveries. */
107
+ readonly eventId: string;
108
+ /** Matches `PaymentResult.providerReference` / the payment this event is about. */
109
+ readonly paymentReference: string;
110
+ readonly amount?: Money;
111
+ /** The provider's own event payload, for anything this shape does not carry. */
112
+ readonly raw: unknown;
113
+ }
114
+ /** A provider API call failed -- HTTP error, network error, or a response
115
+ * this adapter could not parse. Never thrown for "the payment was declined";
116
+ * that is a normal `PaymentResult`/`RefundResult` with a `failed` status. */
117
+ export declare class PaymentProviderError extends Error {
118
+ readonly provider: 'stripe' | 'vipps';
119
+ readonly status?: number;
120
+ readonly raw?: unknown;
121
+ constructor(provider: 'stripe' | 'vipps', message: string, options?: {
122
+ status?: number;
123
+ raw?: unknown;
124
+ });
125
+ }
126
+ /** A webhook payload failed to verify: missing/malformed signature header,
127
+ * a signature that does not match the payload, or a timestamp outside the
128
+ * replay tolerance. Callers must reject the request (400/401), never act on
129
+ * the event. */
130
+ export declare class WebhookVerificationError extends Error {
131
+ readonly provider: 'stripe' | 'vipps';
132
+ constructor(provider: 'stripe' | 'vipps', message: string);
133
+ }
package/dist/types.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shared vocabulary for both adapters. Nothing here is provider-specific;
3
+ * `stripe.ts` and `vipps.ts` each translate their provider's own wire shape
4
+ * into these types and back.
5
+ */
6
+ /** A provider API call failed -- HTTP error, network error, or a response
7
+ * this adapter could not parse. Never thrown for "the payment was declined";
8
+ * that is a normal `PaymentResult`/`RefundResult` with a `failed` status. */
9
+ export class PaymentProviderError extends Error {
10
+ provider;
11
+ status;
12
+ raw;
13
+ constructor(provider, message, options = {}) {
14
+ super(`payments(${provider}): ${message}`);
15
+ this.name = 'PaymentProviderError';
16
+ this.provider = provider;
17
+ this.status = options.status;
18
+ this.raw = options.raw;
19
+ }
20
+ }
21
+ /** A webhook payload failed to verify: missing/malformed signature header,
22
+ * a signature that does not match the payload, or a timestamp outside the
23
+ * replay tolerance. Callers must reject the request (400/401), never act on
24
+ * the event. */
25
+ export class WebhookVerificationError extends Error {
26
+ provider;
27
+ constructor(provider, message) {
28
+ super(`payments(${provider}) webhook verification failed: ${message}`);
29
+ this.name = 'WebhookVerificationError';
30
+ this.provider = provider;
31
+ }
32
+ }
@@ -0,0 +1,55 @@
1
+ import { type FetchLike } from './http.js';
2
+ import type { PaymentProvider } from './provider.js';
3
+ import { type NormalizedWebhookEvent } from './types.js';
4
+ export interface VippsProviderOptions {
5
+ /** `Ocp-Apim-Subscription-Key`: the sales unit's subscription key. */
6
+ readonly subscriptionKey: string;
7
+ /** `Merchant-Serial-Number`: the sales unit's MSN. */
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;
14
+ readonly fetch?: FetchLike;
15
+ /** Override for testing. Defaults to `https://api.vipps.no`. */
16
+ readonly baseUrl?: string;
17
+ }
18
+ export declare function createVippsProvider(options: VippsProviderOptions): PaymentProvider;
19
+ export interface VippsWebhookHeaders {
20
+ /** `Authorization`, e.g. `"HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature=..."`. */
21
+ readonly authorization: string;
22
+ readonly xMsDate: string;
23
+ readonly xMsContentSha256: string;
24
+ readonly host: string;
25
+ }
26
+ export interface VippsWebhookOptions {
27
+ /** Seconds of clock skew to tolerate between `x-ms-date` and now.
28
+ * Default: 300 (5 minutes), matching the tolerance this package also uses
29
+ * for Stripe. */
30
+ readonly toleranceSeconds?: number;
31
+ readonly now?: () => number;
32
+ }
33
+ /**
34
+ * Verifies a Vipps MobilePay webhook request, by hand, against the
35
+ * Azure-API-Management-style HMAC scheme documented at
36
+ * https://developer.vippsmobilepay.com/docs/APIs/webhooks-api/request-authentication/
37
+ * (Vipps' webhook infrastructure runs on Azure APIM; there is no `v1=`/`t=`
38
+ * header the way Stripe has):
39
+ *
40
+ * 1. Recompute `sha256(rawBody)`, base64-encoded; it must match
41
+ * `x-ms-content-sha256` (this also authenticates the body, not just
42
+ * the headers).
43
+ * 2. Build the string-to-sign:
44
+ * `"${method}\n${pathAndQuery}\n${xMsDate};${host};${xMsContentSha256}"`.
45
+ * 3. HMAC-SHA256(base64-decoded webhook secret, string-to-sign),
46
+ * base64-encoded.
47
+ * 4. Constant-time compare to the `Signature=` value inside
48
+ * `Authorization`; reject if `x-ms-date` is outside `toleranceSeconds`
49
+ * of now.
50
+ *
51
+ * `method` and `pathAndQuery` must be exactly what Vipps signed: the HTTP
52
+ * method of the webhook request, and the request path plus query string
53
+ * (e.g. `"/webhooks/order-123"`), not a normalized or re-routed form of it.
54
+ */
55
+ export declare function verifyVippsWebhook(rawBody: string, requestHeaders: VippsWebhookHeaders, webhookSecret: string, method: string, pathAndQuery: string, options?: VippsWebhookOptions): NormalizedWebhookEvent;
package/dist/vipps.js ADDED
@@ -0,0 +1,314 @@
1
+ import { defaultFetch } from './http.js';
2
+ import { PaymentProviderError, WebhookVerificationError, } from './types.js';
3
+ /**
4
+ * The Vipps MobilePay adapter. Plain HTTP against the ePayment and Recurring
5
+ * REST APIs -- no Vipps SDK (there is no single official Node one). See
6
+ * docs/adr/0003-plain-http-no-provider-sdks.md.
7
+ *
8
+ * Sources (fetched 2026-09-23, primary docs, no live calls):
9
+ * - https://developer.vippsmobilepay.com/api/epayment/ (create, capture, cancel, refund)
10
+ * - https://developer.vippsmobilepay.com/api/recurring/ (agreements, charges)
11
+ * - https://developer.vippsmobilepay.com/docs/APIs/webhooks-api/request-authentication/
12
+ *
13
+ * Two things v1 deliberately leaves to the caller, recorded here and in the
14
+ * ledger's Fog rather than guessed at:
15
+ * - the OAuth access token (`POST /accesstoken/get`): this adapter takes
16
+ * an already-valid `accessToken` and never fetches or refreshes one
17
+ * itself, the same "caller resolves the credential" shape as `stripe.ts`.
18
+ * - the exact `state` enum on a payment/capture/refund response: the
19
+ * fetched docs were not fully explicit about it, so `derivePaymentStatus`
20
+ * below falls back to the `aggregate.*Amount` fields, which the docs did
21
+ * confirm, whenever `state` is absent or unrecognised.
22
+ */
23
+ import { hmacSha256Base64, safeEqual, sha256Base64 } from './webhook-crypto.js';
24
+ const PAYMENT_STATE = {
25
+ CREATED: 'pending',
26
+ AUTHORIZED: 'authorized',
27
+ CAPTURED: 'captured',
28
+ CANCELLED: 'canceled',
29
+ TERMINATED: 'canceled',
30
+ REFUNDED: 'refunded',
31
+ EXPIRED: 'failed',
32
+ ABORTED: 'canceled',
33
+ };
34
+ /** Best-known `state` first; falls back to the documented `aggregate.*Amount`
35
+ * fields when `state` is missing or not one of the values above (see the
36
+ * module doc comment -- the exact enum was not confirmed from primary
37
+ * docs). Never throws on an unrecognised shape: worst case is `'pending'`. */
38
+ function derivePaymentStatus(payment) {
39
+ const known = payment.state ? PAYMENT_STATE[payment.state] : undefined;
40
+ if (known)
41
+ return known;
42
+ const aggregate = payment.aggregate;
43
+ if (!aggregate)
44
+ return 'pending';
45
+ const captured = aggregate.capturedAmount?.value ?? 0;
46
+ const refunded = aggregate.refundedAmount?.value ?? 0;
47
+ const cancelled = aggregate.cancelledAmount?.value ?? 0;
48
+ const authorized = aggregate.authorizedAmount?.value ?? 0;
49
+ if (refunded > 0 && refunded >= captured && captured > 0)
50
+ return 'refunded';
51
+ if (refunded > 0)
52
+ return 'partially_refunded';
53
+ if (cancelled > 0)
54
+ return 'canceled';
55
+ if (captured > 0)
56
+ return 'captured';
57
+ if (authorized > 0)
58
+ return 'authorized';
59
+ return 'pending';
60
+ }
61
+ function toMoney(amount, fallback) {
62
+ if (!amount)
63
+ return fallback;
64
+ return { value: amount.value, currency: amount.currency.toUpperCase() };
65
+ }
66
+ function toPaymentResult(payment, fallbackAmount) {
67
+ return {
68
+ provider: 'vipps',
69
+ providerReference: payment.reference,
70
+ status: derivePaymentStatus(payment),
71
+ amount: toMoney(payment.aggregate?.authorizedAmount ?? payment.amount, fallbackAmount),
72
+ redirectUrl: payment.redirectUrl,
73
+ raw: payment,
74
+ };
75
+ }
76
+ /** `due` for a Recurring charge must be at least one day ahead (Vipps
77
+ * Recurring API). Tomorrow, in the caller's local calendar, as `YYYY-MM-DD`. */
78
+ function tomorrowIsoDate() {
79
+ const d = new Date(Date.now() + 24 * 60 * 60 * 1000);
80
+ return d.toISOString().slice(0, 10);
81
+ }
82
+ export function createVippsProvider(options) {
83
+ const fetchImpl = options.fetch ?? defaultFetch();
84
+ const baseUrl = options.baseUrl ?? 'https://api.vipps.no';
85
+ function headers(idempotencyKey) {
86
+ const h = {
87
+ 'Content-Type': 'application/json',
88
+ 'Ocp-Apim-Subscription-Key': options.subscriptionKey,
89
+ 'Merchant-Serial-Number': options.merchantSerialNumber,
90
+ Authorization: `Bearer ${options.accessToken}`,
91
+ };
92
+ if (idempotencyKey)
93
+ h['Idempotency-Key'] = idempotencyKey;
94
+ return h;
95
+ }
96
+ async function post(path, body, idempotencyKey) {
97
+ const response = await fetchImpl(`${baseUrl}${path}`, {
98
+ method: 'POST',
99
+ headers: headers(idempotencyKey),
100
+ body: JSON.stringify(body),
101
+ });
102
+ const parsed = await response.json();
103
+ if (!response.ok) {
104
+ throw new PaymentProviderError('vipps', `HTTP ${response.status}`, {
105
+ status: response.status,
106
+ raw: parsed,
107
+ });
108
+ }
109
+ return parsed;
110
+ }
111
+ async function get(path) {
112
+ const response = await fetchImpl(`${baseUrl}${path}`, {
113
+ method: 'GET',
114
+ headers: headers(undefined),
115
+ });
116
+ const parsed = await response.json();
117
+ if (!response.ok) {
118
+ throw new PaymentProviderError('vipps', `HTTP ${response.status}`, {
119
+ status: response.status,
120
+ raw: parsed,
121
+ });
122
+ }
123
+ return parsed;
124
+ }
125
+ return {
126
+ name: 'vipps',
127
+ async createPayment(input) {
128
+ const payment = await post('/epayment/v1/payments', {
129
+ reference: input.reference,
130
+ amount: { value: input.amount.value, currency: input.amount.currency.toUpperCase() },
131
+ paymentMethod: { type: 'WALLET' },
132
+ userFlow: 'WEB_REDIRECT',
133
+ returnUrl: input.returnUrl,
134
+ paymentDescription: input.description,
135
+ }, input.idempotencyKey ?? input.reference);
136
+ return toPaymentResult(payment, input.amount);
137
+ },
138
+ async capturePayment(providerReference, input = {}) {
139
+ // Unlike Stripe, Vipps requires an explicit modification amount -- it
140
+ // has no "capture whatever was authorized" shortcut. A missing
141
+ // `input.amount` (v1's "full capture" convention) needs one extra
142
+ // lookup to find out what that amount is.
143
+ const amount = input.amount ??
144
+ (await get(`/epayment/v1/payments/${providerReference}`)).aggregate
145
+ ?.authorizedAmount;
146
+ if (!amount) {
147
+ throw new PaymentProviderError('vipps', `payment ${providerReference} has no authorized amount to capture`);
148
+ }
149
+ const payment = await post(`/epayment/v1/payments/${providerReference}/capture`, { modificationAmount: { value: amount.value, currency: amount.currency.toUpperCase() } }, input.idempotencyKey);
150
+ // Defence in depth: never attribute the result to a different payment
151
+ // than the one this call actually captured, even if the response body
152
+ // claims otherwise.
153
+ if (payment.reference !== providerReference) {
154
+ throw new PaymentProviderError('vipps', `capture response reference "${payment.reference}" does not match the requested payment "${providerReference}"`, { raw: payment });
155
+ }
156
+ return toPaymentResult(payment, { value: amount.value, currency: amount.currency });
157
+ },
158
+ async refundPayment(providerReference, input = {}) {
159
+ const amount = input.amount ??
160
+ (await get(`/epayment/v1/payments/${providerReference}`)).aggregate
161
+ ?.capturedAmount;
162
+ if (!amount) {
163
+ throw new PaymentProviderError('vipps', `payment ${providerReference} has no captured amount to refund`);
164
+ }
165
+ const payment = await post(`/epayment/v1/payments/${providerReference}/refund`, { modificationAmount: { value: amount.value, currency: amount.currency.toUpperCase() } }, input.idempotencyKey);
166
+ // Same integrity check as capturePayment, even though this method
167
+ // does not read `payment.reference` for its own output below --
168
+ // a mismatch here still means the response is not about the payment
169
+ // this call requested, which is worth failing loudly on.
170
+ if (payment.reference !== providerReference) {
171
+ throw new PaymentProviderError('vipps', `refund response reference "${payment.reference}" does not match the requested payment "${providerReference}"`, { raw: payment });
172
+ }
173
+ const status = derivePaymentStatus(payment);
174
+ return {
175
+ provider: 'vipps',
176
+ // Vipps' ePayment API returns the mutated payment resource, not a
177
+ // separate refund id -- there is nothing else to use as the refund's
178
+ // own reference. See the module doc comment.
179
+ refundReference: providerReference,
180
+ paymentReference: providerReference,
181
+ status: status === 'refunded' || status === 'partially_refunded' ? 'succeeded' : 'pending',
182
+ amount: toMoney(payment.aggregate?.refundedAmount, {
183
+ value: amount.value,
184
+ currency: amount.currency,
185
+ }),
186
+ raw: payment,
187
+ };
188
+ },
189
+ async createRecurringAgreement(input) {
190
+ const agreement = await post('/recurring/v3/agreements', {
191
+ pricing: {
192
+ type: 'LEGACY',
193
+ amount: input.amount.value,
194
+ currency: input.amount.currency.toUpperCase(),
195
+ },
196
+ interval: {
197
+ unit: (input.interval?.unit ?? 'month').toUpperCase(),
198
+ count: input.interval?.count ?? 1,
199
+ },
200
+ productName: input.productName,
201
+ merchantRedirectUrl: input.returnUrl,
202
+ merchantAgreementUrl: input.managementUrl ?? input.returnUrl,
203
+ }, input.idempotencyKey ?? input.reference);
204
+ return {
205
+ provider: 'vipps',
206
+ agreementReference: agreement.agreementId,
207
+ // A freshly created agreement always starts unapproved: the payer
208
+ // has not yet confirmed it at `vippsConfirmationUrl`.
209
+ status: 'pending',
210
+ confirmationUrl: agreement.vippsConfirmationUrl,
211
+ raw: agreement,
212
+ };
213
+ },
214
+ async chargeRecurringAgreement(agreementReference, input) {
215
+ const charge = await post(`/recurring/v3/agreements/${agreementReference}/charges`, {
216
+ amount: input.amount.value,
217
+ currency: input.amount.currency.toUpperCase(),
218
+ transactionType: 'DIRECT_CAPTURE',
219
+ due: input.dueDate ?? tomorrowIsoDate(),
220
+ retryDays: 0,
221
+ description: input.description,
222
+ }, input.idempotencyKey);
223
+ return {
224
+ provider: 'vipps',
225
+ providerReference: charge.chargeId,
226
+ // Recurring charges are created, then processed asynchronously by
227
+ // Vipps -- there is no synchronous "it went through" in this
228
+ // response. The caller learns the outcome from a webhook or a GET.
229
+ status: 'pending',
230
+ amount: input.amount,
231
+ raw: charge,
232
+ };
233
+ },
234
+ };
235
+ }
236
+ const WEBHOOK_EVENT_NAME = {
237
+ CREATED: 'payment.created',
238
+ AUTHORIZED: 'payment.authorized',
239
+ CAPTURED: 'payment.captured',
240
+ CANCELLED: 'payment.cancelled',
241
+ TERMINATED: 'payment.cancelled',
242
+ REFUNDED: 'payment.refunded',
243
+ EXPIRED: 'payment.expired',
244
+ ABORTED: 'payment.cancelled',
245
+ };
246
+ /**
247
+ * Verifies a Vipps MobilePay webhook request, by hand, against the
248
+ * Azure-API-Management-style HMAC scheme documented at
249
+ * https://developer.vippsmobilepay.com/docs/APIs/webhooks-api/request-authentication/
250
+ * (Vipps' webhook infrastructure runs on Azure APIM; there is no `v1=`/`t=`
251
+ * header the way Stripe has):
252
+ *
253
+ * 1. Recompute `sha256(rawBody)`, base64-encoded; it must match
254
+ * `x-ms-content-sha256` (this also authenticates the body, not just
255
+ * the headers).
256
+ * 2. Build the string-to-sign:
257
+ * `"${method}\n${pathAndQuery}\n${xMsDate};${host};${xMsContentSha256}"`.
258
+ * 3. HMAC-SHA256(base64-decoded webhook secret, string-to-sign),
259
+ * base64-encoded.
260
+ * 4. Constant-time compare to the `Signature=` value inside
261
+ * `Authorization`; reject if `x-ms-date` is outside `toleranceSeconds`
262
+ * of now.
263
+ *
264
+ * `method` and `pathAndQuery` must be exactly what Vipps signed: the HTTP
265
+ * method of the webhook request, and the request path plus query string
266
+ * (e.g. `"/webhooks/order-123"`), not a normalized or re-routed form of it.
267
+ */
268
+ export function verifyVippsWebhook(rawBody, requestHeaders, webhookSecret, method, pathAndQuery, options = {}) {
269
+ const toleranceSeconds = options.toleranceSeconds ?? 300;
270
+ const now = options.now ?? Date.now;
271
+ const actualContentHash = sha256Base64(rawBody);
272
+ if (!safeEqual(actualContentHash, requestHeaders.xMsContentSha256)) {
273
+ throw new WebhookVerificationError('vipps', 'x-ms-content-sha256 does not match the request body');
274
+ }
275
+ const signatureMatch = /Signature=([^&]+)$/.exec(requestHeaders.authorization);
276
+ if (!signatureMatch) {
277
+ throw new WebhookVerificationError('vipps', 'Authorization header is missing a "Signature=" value');
278
+ }
279
+ const providedSignature = signatureMatch[1];
280
+ const stringToSign = [
281
+ method.toUpperCase(),
282
+ pathAndQuery,
283
+ `${requestHeaders.xMsDate};${requestHeaders.host};${requestHeaders.xMsContentSha256}`,
284
+ ].join('\n');
285
+ const expectedSignature = hmacSha256Base64(webhookSecret, stringToSign);
286
+ if (!safeEqual(providedSignature, expectedSignature)) {
287
+ throw new WebhookVerificationError('vipps', 'signature does not match the request');
288
+ }
289
+ const requestTimeMs = Date.parse(requestHeaders.xMsDate);
290
+ const ageSeconds = Math.abs((now() - requestTimeMs) / 1000);
291
+ if (!Number.isFinite(ageSeconds) || ageSeconds > toleranceSeconds) {
292
+ throw new WebhookVerificationError('vipps', `x-ms-date is ${Math.round(ageSeconds)}s old, outside the ${toleranceSeconds}s tolerance (possible replay)`);
293
+ }
294
+ let event;
295
+ try {
296
+ event = JSON.parse(rawBody);
297
+ }
298
+ catch {
299
+ throw new WebhookVerificationError('vipps', 'payload is not valid JSON');
300
+ }
301
+ const mapped = event.name ? WEBHOOK_EVENT_NAME[event.name] : undefined;
302
+ const type = event.success === false ? 'payment.failed' : (mapped ?? 'unknown');
303
+ return {
304
+ provider: 'vipps',
305
+ type,
306
+ eventId: event.idempotencyKey ??
307
+ `${event.reference ?? 'unknown'}:${event.name ?? 'unknown'}:${event.timestamp ?? ''}`,
308
+ paymentReference: event.reference ?? '',
309
+ amount: event.amount
310
+ ? { value: event.amount.value, currency: event.amount.currency.toUpperCase() }
311
+ : undefined,
312
+ raw: event,
313
+ };
314
+ }
@@ -0,0 +1,16 @@
1
+ /** Constant-time comparison of two strings of possibly-different length.
2
+ * `crypto.timingSafeEqual` throws on a length mismatch, which would itself
3
+ * leak timing information (a throw is fast; the real compare is not), so a
4
+ * length mismatch is treated as "not equal" up front rather than crashing
5
+ * or falling back to `===`. */
6
+ export declare function safeEqual(a: string, b: string): boolean;
7
+ /** hex-encoded HMAC-SHA256, as Stripe's `Stripe-Signature` header uses. */
8
+ export declare function hmacSha256Hex(key: string, message: string): string;
9
+ /** base64-encoded HMAC-SHA256, as Vipps' Azure-APIM-style `Authorization`
10
+ * header uses. `key` is the base64-encoded secret Vipps issues when a
11
+ * webhook is registered; it is decoded to raw bytes before use as the HMAC
12
+ * key, matching Azure API Management's HMAC validation scheme (Vipps'
13
+ * webhook infrastructure runs on APIM). */
14
+ export declare function hmacSha256Base64(base64Key: string, message: string): string;
15
+ /** base64-encoded SHA-256 of `body`, as Vipps' `x-ms-content-sha256` header carries. */
16
+ export declare function sha256Base64(body: string): string;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared HMAC primitives for both adapters' webhook verification. Neither
3
+ * `stripe.ts` nor `vipps.ts` calls `node:crypto` directly outside this file,
4
+ * so the one place that must be correct is small and gets its own tests.
5
+ */
6
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
7
+ /** Constant-time comparison of two strings of possibly-different length.
8
+ * `crypto.timingSafeEqual` throws on a length mismatch, which would itself
9
+ * leak timing information (a throw is fast; the real compare is not), so a
10
+ * length mismatch is treated as "not equal" up front rather than crashing
11
+ * or falling back to `===`. */
12
+ export function safeEqual(a, b) {
13
+ const bufA = Buffer.from(a, 'utf8');
14
+ const bufB = Buffer.from(b, 'utf8');
15
+ if (bufA.length !== bufB.length)
16
+ return false;
17
+ return timingSafeEqual(bufA, bufB);
18
+ }
19
+ /** hex-encoded HMAC-SHA256, as Stripe's `Stripe-Signature` header uses. */
20
+ export function hmacSha256Hex(key, message) {
21
+ return createHmac('sha256', key).update(message, 'utf8').digest('hex');
22
+ }
23
+ /** base64-encoded HMAC-SHA256, as Vipps' Azure-APIM-style `Authorization`
24
+ * header uses. `key` is the base64-encoded secret Vipps issues when a
25
+ * webhook is registered; it is decoded to raw bytes before use as the HMAC
26
+ * key, matching Azure API Management's HMAC validation scheme (Vipps'
27
+ * webhook infrastructure runs on APIM). */
28
+ export function hmacSha256Base64(base64Key, message) {
29
+ const keyBytes = Buffer.from(base64Key, 'base64');
30
+ return createHmac('sha256', keyBytes).update(message, 'utf8').digest('base64');
31
+ }
32
+ /** base64-encoded SHA-256 of `body`, as Vipps' `x-ms-content-sha256` header carries. */
33
+ export function sha256Base64(body) {
34
+ return createHash('sha256').update(body, 'utf8').digest('base64');
35
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@wtfalch/payments",
3
+ "version": "0.1.0",
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. Holds no card data.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/wtfalch/payments",
8
+ "directory": "packages/payments"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "sideEffects": false,
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "engines": {
29
+ "node": ">=22.0.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22",
33
+ "typescript": "^5.9.0",
34
+ "vitest": "^4.1.6"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.build.json",
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "vitest run"
40
+ }
41
+ }