@wtfalch/payments 0.2.0 → 0.3.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 +109 -16
- package/dist/db.d.ts +24 -0
- package/dist/db.js +1 -0
- package/dist/http.d.ts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/migrate.d.ts +12 -0
- package/dist/migrate.js +88 -0
- package/dist/migrations/0001_payments.sql +75 -0
- package/dist/provider.d.ts +5 -0
- package/dist/store.d.ts +181 -0
- package/dist/store.js +534 -0
- package/dist/stripe.js +25 -1
- package/dist/types.d.ts +19 -3
- package/dist/vipps.d.ts +28 -5
- package/dist/vipps.js +154 -15
- package/package.json +13 -13
package/dist/vipps.js
CHANGED
|
@@ -5,22 +5,41 @@ import { PaymentProviderError, WebhookVerificationError, } from './types.js';
|
|
|
5
5
|
* REST APIs -- no Vipps SDK (there is no single official Node one). See
|
|
6
6
|
* docs/adr/0003-plain-http-no-provider-sdks.md.
|
|
7
7
|
*
|
|
8
|
-
* Sources (fetched 2026-09-23, primary docs, no live calls):
|
|
8
|
+
* Sources (fetched 2026-09-23/24, primary docs, no live calls):
|
|
9
9
|
* - https://developer.vippsmobilepay.com/api/epayment/ (create, capture, cancel, refund)
|
|
10
|
-
* - https://developer.vippsmobilepay.com/api/recurring/ (agreements, charges)
|
|
10
|
+
* - https://developer.vippsmobilepay.com/api/recurring/ (agreements, charges, status enum)
|
|
11
11
|
* - https://developer.vippsmobilepay.com/docs/APIs/webhooks-api/request-authentication/
|
|
12
|
+
* - https://developer.vippsmobilepay.com/docs/APIs/webhooks-api/events/ (event catalogue)
|
|
13
|
+
* - https://developer.vippsmobilepay.com/docs/knowledge-base/across-borders/ (Nordic markets)
|
|
14
|
+
* - https://developer.vippsmobilepay.com/docs/APIs/recurring-api/recurring-api-guide/
|
|
12
15
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* One Recurring/ePayment API serves all three Nordic markets (see
|
|
17
|
+
* docs/adr/0006-payment-store.md's "MobilePay" section and this package's
|
|
18
|
+
* README, "MobilePay (Denmark, Finland)"): same base URL, same headers, same
|
|
19
|
+
* request/response shapes. What differs is the sales unit's own currency
|
|
20
|
+
* (NOK/DKK/EUR) and per-market amount ceiling, both the caller's concern
|
|
21
|
+
* (`Money.currency`, `CreateRecurringAgreementInput.amount`), never this
|
|
22
|
+
* adapter's -- `createVippsProvider` needs no market-specific option.
|
|
23
|
+
*
|
|
24
|
+
* `docs/adr/0007-vipps-access-token-caching.md` covers this adapter now
|
|
25
|
+
* fetching and caching its own OAuth access token (`POST /accesstoken/get`),
|
|
26
|
+
* superseding v1's "the caller resolves it" stance (see that ADR for why
|
|
27
|
+
* this does not force the service shape -- docs/adr/0001).
|
|
28
|
+
*
|
|
29
|
+
* Still not confirmed from primary docs: the exact `state` enum on an
|
|
30
|
+
* ePayment payment/capture/refund response (`derivePaymentStatus` below
|
|
31
|
+
* falls back to the `aggregate.*Amount` fields whenever `state` is absent
|
|
32
|
+
* or unrecognised), and the exact field carrying a Recurring webhook
|
|
33
|
+
* event's own id (`normalizeRecurringEvent` below synthesizes one the same
|
|
34
|
+
* way the ePayment side already did, for the same reason).
|
|
22
35
|
*/
|
|
23
36
|
import { hmacSha256Base64, safeEqual, sha256Base64 } from './webhook-crypto.js';
|
|
37
|
+
const AGREEMENT_STATUS = {
|
|
38
|
+
PENDING: 'pending',
|
|
39
|
+
ACTIVE: 'active',
|
|
40
|
+
STOPPED: 'stopped',
|
|
41
|
+
EXPIRED: 'expired',
|
|
42
|
+
};
|
|
24
43
|
const PAYMENT_STATE = {
|
|
25
44
|
CREATED: 'pending',
|
|
26
45
|
AUTHORIZED: 'authorized',
|
|
@@ -80,14 +99,80 @@ function tomorrowIsoDate() {
|
|
|
80
99
|
return d.toISOString().slice(0, 10);
|
|
81
100
|
}
|
|
82
101
|
export function createVippsProvider(options) {
|
|
102
|
+
if (!options.accessToken && !(options.clientId && options.clientSecret)) {
|
|
103
|
+
throw new PaymentProviderError('vipps', 'createVippsProvider needs either accessToken, or both clientId and clientSecret');
|
|
104
|
+
}
|
|
83
105
|
const fetchImpl = options.fetch ?? defaultFetch();
|
|
84
106
|
const baseUrl = options.baseUrl ?? 'https://api.vipps.no';
|
|
85
|
-
|
|
107
|
+
const tokenUrl = options.tokenUrl ?? 'https://api.vipps.no/accesstoken/get';
|
|
108
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
109
|
+
const refreshMarginMs = (options.tokenRefreshMarginSeconds ?? 60) * 1000;
|
|
110
|
+
// Token cache, scoped to this provider instance (one per `subscriptionKey`
|
|
111
|
+
// + `merchantSerialNumber` pair, ordinarily). `inFlight` is the one thing
|
|
112
|
+
// that makes this concurrency-safe: N callers racing `resolveAccessToken`
|
|
113
|
+
// while the cache is empty or expired share the same `POST
|
|
114
|
+
// /accesstoken/get` promise instead of each firing their own -- assigned
|
|
115
|
+
// before any `await`, so there is no gap between "check the cache" and
|
|
116
|
+
// "start a refresh" for a second caller to land in. Cleared in `finally`,
|
|
117
|
+
// on success and on failure alike: a failed refresh must not wedge every
|
|
118
|
+
// later call behind a promise that has already rejected.
|
|
119
|
+
let cached = null;
|
|
120
|
+
let inFlight = null;
|
|
121
|
+
async function fetchAccessToken() {
|
|
122
|
+
const response = await fetchImpl(tokenUrl, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: {
|
|
125
|
+
client_id: options.clientId,
|
|
126
|
+
client_secret: options.clientSecret,
|
|
127
|
+
'Ocp-Apim-Subscription-Key': options.subscriptionKey,
|
|
128
|
+
'Merchant-Serial-Number': options.merchantSerialNumber,
|
|
129
|
+
},
|
|
130
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
131
|
+
});
|
|
132
|
+
const parsed = await response.json();
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new PaymentProviderError('vipps', `access token request failed: HTTP ${response.status}`, {
|
|
135
|
+
status: response.status,
|
|
136
|
+
raw: parsed,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const body = parsed;
|
|
140
|
+
if (!body.access_token) {
|
|
141
|
+
throw new PaymentProviderError('vipps', 'access token response has no "access_token" field', {
|
|
142
|
+
raw: parsed,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const expiresInSeconds = Number(body.expires_in ?? 0);
|
|
146
|
+
cached = {
|
|
147
|
+
token: body.access_token,
|
|
148
|
+
// A missing/unparsable expires_in caches nothing usable: expiresAtMs
|
|
149
|
+
// stays in the past, so the very next call refreshes again rather
|
|
150
|
+
// than reusing a token whose real lifetime is unknown.
|
|
151
|
+
expiresAtMs: Number.isFinite(expiresInSeconds) && expiresInSeconds > 0
|
|
152
|
+
? Date.now() + expiresInSeconds * 1000
|
|
153
|
+
: 0,
|
|
154
|
+
};
|
|
155
|
+
return cached.token;
|
|
156
|
+
}
|
|
157
|
+
async function resolveAccessToken() {
|
|
158
|
+
if (options.accessToken)
|
|
159
|
+
return options.accessToken; // v1 shape: caller-managed, never cached/refreshed here
|
|
160
|
+
if (cached && cached.expiresAtMs - refreshMarginMs > Date.now())
|
|
161
|
+
return cached.token;
|
|
162
|
+
if (!inFlight) {
|
|
163
|
+
inFlight = fetchAccessToken().finally(() => {
|
|
164
|
+
inFlight = null;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return inFlight;
|
|
168
|
+
}
|
|
169
|
+
async function resolveHeaders(idempotencyKey) {
|
|
170
|
+
const token = await resolveAccessToken();
|
|
86
171
|
const h = {
|
|
87
172
|
'Content-Type': 'application/json',
|
|
88
173
|
'Ocp-Apim-Subscription-Key': options.subscriptionKey,
|
|
89
174
|
'Merchant-Serial-Number': options.merchantSerialNumber,
|
|
90
|
-
Authorization: `Bearer ${
|
|
175
|
+
Authorization: `Bearer ${token}`,
|
|
91
176
|
};
|
|
92
177
|
if (idempotencyKey)
|
|
93
178
|
h['Idempotency-Key'] = idempotencyKey;
|
|
@@ -96,8 +181,9 @@ export function createVippsProvider(options) {
|
|
|
96
181
|
async function post(path, body, idempotencyKey) {
|
|
97
182
|
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
98
183
|
method: 'POST',
|
|
99
|
-
headers:
|
|
184
|
+
headers: await resolveHeaders(idempotencyKey),
|
|
100
185
|
body: JSON.stringify(body),
|
|
186
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
101
187
|
});
|
|
102
188
|
const parsed = await response.json();
|
|
103
189
|
if (!response.ok) {
|
|
@@ -111,7 +197,8 @@ export function createVippsProvider(options) {
|
|
|
111
197
|
async function get(path) {
|
|
112
198
|
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
113
199
|
method: 'GET',
|
|
114
|
-
headers:
|
|
200
|
+
headers: await resolveHeaders(undefined),
|
|
201
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
115
202
|
});
|
|
116
203
|
const parsed = await response.json();
|
|
117
204
|
if (!response.ok) {
|
|
@@ -247,6 +334,16 @@ export function createVippsProvider(options) {
|
|
|
247
334
|
raw: charge,
|
|
248
335
|
};
|
|
249
336
|
},
|
|
337
|
+
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
|
+
};
|
|
346
|
+
},
|
|
250
347
|
};
|
|
251
348
|
}
|
|
252
349
|
const WEBHOOK_EVENT_NAME = {
|
|
@@ -259,6 +356,42 @@ const WEBHOOK_EVENT_NAME = {
|
|
|
259
356
|
EXPIRED: 'payment.expired',
|
|
260
357
|
ABORTED: 'payment.cancelled',
|
|
261
358
|
};
|
|
359
|
+
/** The Recurring API's own webhook event catalogue -- a different namespace
|
|
360
|
+
* from the ePayment `name` field `WEBHOOK_EVENT_NAME` above maps.
|
|
361
|
+
* https://developer.vippsmobilepay.com/docs/APIs/webhooks-api/events/
|
|
362
|
+
* (fetched 2026-09-24 via a documentation-summarizing tool, the same
|
|
363
|
+
* caveat docs/adr/0004 already carries for the ePayment side -- not
|
|
364
|
+
* checked against one real signed Vipps delivery). */
|
|
365
|
+
const RECURRING_EVENT_TYPE = {
|
|
366
|
+
'recurring.agreement-activated.v1': 'agreement.activated',
|
|
367
|
+
'recurring.agreement-rejected.v1': 'agreement.rejected',
|
|
368
|
+
'recurring.agreement-stopped.v1': 'agreement.stopped',
|
|
369
|
+
'recurring.agreement-expired.v1': 'agreement.expired',
|
|
370
|
+
'recurring.charge-reserved.v1': 'charge.reserved',
|
|
371
|
+
'recurring.charge-captured.v1': 'charge.captured',
|
|
372
|
+
'recurring.charge-canceled.v1': 'charge.canceled',
|
|
373
|
+
'recurring.charge-refunded.v1': 'charge.refunded',
|
|
374
|
+
'recurring.charge-failed.v1': 'charge.failed',
|
|
375
|
+
'recurring.charge-creation-failed.v1': 'charge.failed',
|
|
376
|
+
};
|
|
377
|
+
/** A Recurring event names one agreement always, and a charge only for a
|
|
378
|
+
* `charge.*` type -- `paymentReference` (which `store.ts` matches against
|
|
379
|
+
* `payment_charges.provider_charge_id`) is empty for an `agreement.*` event,
|
|
380
|
+
* which has none. No documented event id field (unlike the ePayment side's
|
|
381
|
+
* `idempotencyKey`), so `eventId` is synthesized from what is documented,
|
|
382
|
+
* the same fallback shape the ePayment path below already uses. */
|
|
383
|
+
function normalizeRecurringEvent(event) {
|
|
384
|
+
const amount = event.amountCaptured ?? event.amountCanceled ?? event.amountRefunded ?? event.amount;
|
|
385
|
+
return {
|
|
386
|
+
provider: 'vipps',
|
|
387
|
+
type: RECURRING_EVENT_TYPE[event.eventType] ?? 'unknown',
|
|
388
|
+
eventId: `${event.agreementId ?? 'unknown'}:${event.chargeId ?? ''}:${event.eventType}:${event.occurred ?? ''}`,
|
|
389
|
+
paymentReference: event.chargeId ?? '',
|
|
390
|
+
agreementReference: event.agreementId,
|
|
391
|
+
amount: amount ? { value: amount.value, currency: amount.currency.toUpperCase() } : undefined,
|
|
392
|
+
raw: event,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
262
395
|
/**
|
|
263
396
|
* Verifies a Vipps MobilePay webhook request, by hand, against the
|
|
264
397
|
* Azure-API-Management-style HMAC scheme documented at
|
|
@@ -314,6 +447,12 @@ export function verifyVippsWebhook(rawBody, requestHeaders, webhookSecret, metho
|
|
|
314
447
|
catch {
|
|
315
448
|
throw new WebhookVerificationError('vipps', 'payload is not valid JSON');
|
|
316
449
|
}
|
|
450
|
+
// Two payload shapes share this endpoint's signature scheme: ePayment's
|
|
451
|
+
// (`name`/`success`, handled below) and the Recurring API's (`eventType`,
|
|
452
|
+
// agreement/charge events -- see normalizeRecurringEvent's doc comment).
|
|
453
|
+
if (event.eventType) {
|
|
454
|
+
return normalizeRecurringEvent(event);
|
|
455
|
+
}
|
|
317
456
|
const mapped = event.name ? WEBHOOK_EVENT_NAME[event.name] : undefined;
|
|
318
457
|
const type = event.success === false ? 'payment.failed' : (mapped ?? 'unknown');
|
|
319
458
|
return {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/payments",
|
|
3
|
-
"version": "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.",
|
|
3
|
+
"version": "0.3.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, plus a provider-neutral Postgres store for agreements and charges. Holds no card data.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/wtfalch/payments",
|
|
@@ -9,16 +9,13 @@
|
|
|
9
9
|
},
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"type": "module",
|
|
12
|
-
"files": [
|
|
13
|
-
"dist",
|
|
14
|
-
"README.md",
|
|
15
|
-
"LICENSE"
|
|
16
|
-
],
|
|
12
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
17
13
|
"exports": {
|
|
18
14
|
".": {
|
|
19
15
|
"types": "./dist/index.d.ts",
|
|
20
16
|
"default": "./dist/index.js"
|
|
21
17
|
},
|
|
18
|
+
"./migrations/*.sql": "./dist/migrations/*.sql",
|
|
22
19
|
"./package.json": "./package.json"
|
|
23
20
|
},
|
|
24
21
|
"sideEffects": false,
|
|
@@ -28,14 +25,17 @@
|
|
|
28
25
|
"engines": {
|
|
29
26
|
"node": ">=22.0.0"
|
|
30
27
|
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.build.json && node scripts/copy-migrations.mjs",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"prepack": "pnpm build"
|
|
33
|
+
},
|
|
31
34
|
"devDependencies": {
|
|
35
|
+
"@electric-sql/pglite": "^0.5.8",
|
|
32
36
|
"@types/node": "^22",
|
|
37
|
+
"postgres": "^3.4.5",
|
|
33
38
|
"typescript": "^5.9.0",
|
|
34
39
|
"vitest": "^4.1.6"
|
|
35
|
-
},
|
|
36
|
-
"scripts": {
|
|
37
|
-
"build": "tsc -p tsconfig.build.json",
|
|
38
|
-
"typecheck": "tsc --noEmit",
|
|
39
|
-
"test": "vitest run"
|
|
40
40
|
}
|
|
41
|
-
}
|
|
41
|
+
}
|