@wtfalch/payments 0.1.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.d.ts CHANGED
@@ -6,14 +6,37 @@ export interface VippsProviderOptions {
6
6
  readonly subscriptionKey: string;
7
7
  /** `Merchant-Serial-Number`: the sales unit's MSN. */
8
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;
9
+ /**
10
+ * A valid OAuth access token, already fetched. Given this, the adapter
11
+ * never calls `POST /accesstoken/get` itself and never refreshes it --
12
+ * the caller must, on its own schedule. This is v1's shape (0.2.x and
13
+ * earlier), kept so an existing caller migrates by adding
14
+ * `clientId`/`clientSecret` on its own timeline rather than in lockstep
15
+ * with a version bump.
16
+ *
17
+ * Exactly one of `accessToken` or `clientId`+`clientSecret` must be given.
18
+ */
19
+ readonly accessToken?: string;
20
+ /** The sales unit's OAuth client id, for this adapter to fetch and cache
21
+ * its own access token. Requires `clientSecret`. See
22
+ * docs/adr/0007-vipps-access-token-caching.md. */
23
+ readonly clientId?: string;
24
+ /** The sales unit's OAuth client secret. Requires `clientId`. */
25
+ readonly clientSecret?: string;
26
+ /** Seconds of safety margin before a cached token's real expiry at which
27
+ * this adapter fetches a new one instead of reusing it, so a request that
28
+ * starts just under the deadline does not race the token's own expiry.
29
+ * Default: 60. */
30
+ readonly tokenRefreshMarginSeconds?: number;
14
31
  readonly fetch?: FetchLike;
15
32
  /** Override for testing. Defaults to `https://api.vipps.no`. */
16
33
  readonly baseUrl?: string;
34
+ /** Override for testing. Defaults to `https://api.vipps.no/accesstoken/get`. */
35
+ readonly tokenUrl?: string;
36
+ /** Aborts any request (including the token fetch) still pending after
37
+ * this many milliseconds, via `AbortSignal.timeout`. Default: 10 000
38
+ * (10s). A provider that never responds must not hang its caller forever. */
39
+ readonly timeoutMs?: number;
17
40
  }
18
41
  export declare function createVippsProvider(options: VippsProviderOptions): PaymentProvider;
19
42
  export interface VippsWebhookHeaders {
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) {
@@ -187,12 +274,21 @@ export function createVippsProvider(options) {
187
274
  };
188
275
  },
189
276
  async createRecurringAgreement(input) {
190
- const agreement = await post('/recurring/v3/agreements', {
191
- pricing: {
277
+ // pricing.type LEGACY (fixed amount) vs VARIABLE (suggestedMaxAmount,
278
+ // no fixed amount): https://developer.vippsmobilepay.com/api/recurring/
279
+ const pricing = input.variablePricing
280
+ ? {
281
+ type: 'VARIABLE',
282
+ currency: input.amount.currency.toUpperCase(),
283
+ suggestedMaxAmount: input.variablePricing.suggestedMaxAmount,
284
+ }
285
+ : {
192
286
  type: 'LEGACY',
193
287
  amount: input.amount.value,
194
288
  currency: input.amount.currency.toUpperCase(),
195
- },
289
+ };
290
+ const agreement = await post('/recurring/v3/agreements', {
291
+ pricing,
196
292
  interval: {
197
293
  unit: (input.interval?.unit ?? 'month').toUpperCase(),
198
294
  count: input.interval?.count ?? 1,
@@ -212,9 +308,16 @@ export function createVippsProvider(options) {
212
308
  };
213
309
  },
214
310
  async chargeRecurringAgreement(agreementReference, input) {
311
+ // No `currency` field here: the charge schema at
312
+ // https://developer.vippsmobilepay.com/api/recurring/ has no such
313
+ // property -- the charge always uses the agreement's own currency, so
314
+ // sending one was a mismatch with the documented request body (fixed
315
+ // here, not merely renamed). For a VARIABLE agreement, Vipps enforces
316
+ // amount <= the payer's accepted max itself: a charge above it is
317
+ // held `DUE`, not rejected synchronously here, so this adapter does
318
+ // not duplicate that check.
215
319
  const charge = await post(`/recurring/v3/agreements/${agreementReference}/charges`, {
216
320
  amount: input.amount.value,
217
- currency: input.amount.currency.toUpperCase(),
218
321
  transactionType: 'DIRECT_CAPTURE',
219
322
  due: input.dueDate ?? tomorrowIsoDate(),
220
323
  retryDays: 0,
@@ -231,6 +334,16 @@ export function createVippsProvider(options) {
231
334
  raw: charge,
232
335
  };
233
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
+ },
234
347
  };
235
348
  }
236
349
  const WEBHOOK_EVENT_NAME = {
@@ -243,6 +356,42 @@ const WEBHOOK_EVENT_NAME = {
243
356
  EXPIRED: 'payment.expired',
244
357
  ABORTED: 'payment.cancelled',
245
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
+ }
246
395
  /**
247
396
  * Verifies a Vipps MobilePay webhook request, by hand, against the
248
397
  * Azure-API-Management-style HMAC scheme documented at
@@ -298,6 +447,12 @@ export function verifyVippsWebhook(rawBody, requestHeaders, webhookSecret, metho
298
447
  catch {
299
448
  throw new WebhookVerificationError('vipps', 'payload is not valid JSON');
300
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
+ }
301
456
  const mapped = event.name ? WEBHOOK_EVENT_NAME[event.name] : undefined;
302
457
  const type = event.success === false ? 'payment.failed' : (mapped ?? 'unknown');
303
458
  return {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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.",
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
+ }