@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/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
- * 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.
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
- function headers(idempotencyKey) {
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 ${options.accessToken}`,
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: headers(idempotencyKey),
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: headers(undefined),
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.2.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
+ }