@wtfalch/payments 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -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 +5 -1
- 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 +41 -1
- package/dist/store.d.ts +181 -0
- package/dist/store.js +534 -0
- package/dist/stripe.js +105 -1
- package/dist/types.d.ts +49 -3
- package/dist/types.js +9 -0
- package/dist/vipps.d.ts +28 -5
- package/dist/vipps.js +313 -15
- package/package.json +13 -13
package/dist/vipps.js
CHANGED
|
@@ -5,22 +5,47 @@ 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
|
+
* Re-fetched 2026-09-28 (still primary docs, no live calls) for
|
|
17
|
+
* `stopRecurringAgreement`/`updateRecurringAgreement` (`PATCH
|
|
18
|
+
* /recurring/v3/agreements/{id}`), `cancelRecurringCharge` (`DELETE
|
|
19
|
+
* /recurring/v3/agreements/{id}/charges/{chargeId}`) and `cancelPayment`
|
|
20
|
+
* (`POST /epayment/v1/payments/{reference}/cancel`) -- same two URLs above.
|
|
21
|
+
*
|
|
22
|
+
* One Recurring/ePayment API serves all three Nordic markets (see
|
|
23
|
+
* docs/adr/0006-payment-store.md's "MobilePay" section and this package's
|
|
24
|
+
* README, "MobilePay (Denmark, Finland)"): same base URL, same headers, same
|
|
25
|
+
* request/response shapes. What differs is the sales unit's own currency
|
|
26
|
+
* (NOK/DKK/EUR) and per-market amount ceiling, both the caller's concern
|
|
27
|
+
* (`Money.currency`, `CreateRecurringAgreementInput.amount`), never this
|
|
28
|
+
* adapter's -- `createVippsProvider` needs no market-specific option.
|
|
29
|
+
*
|
|
30
|
+
* `docs/adr/0007-vipps-access-token-caching.md` covers this adapter now
|
|
31
|
+
* fetching and caching its own OAuth access token (`POST /accesstoken/get`),
|
|
32
|
+
* superseding v1's "the caller resolves it" stance (see that ADR for why
|
|
33
|
+
* this does not force the service shape -- docs/adr/0001).
|
|
34
|
+
*
|
|
35
|
+
* Still not confirmed from primary docs: the exact `state` enum on an
|
|
36
|
+
* ePayment payment/capture/refund response (`derivePaymentStatus` below
|
|
37
|
+
* falls back to the `aggregate.*Amount` fields whenever `state` is absent
|
|
38
|
+
* or unrecognised), and the exact field carrying a Recurring webhook
|
|
39
|
+
* event's own id (`normalizeRecurringEvent` below synthesizes one the same
|
|
40
|
+
* way the ePayment side already did, for the same reason).
|
|
22
41
|
*/
|
|
23
42
|
import { hmacSha256Base64, safeEqual, sha256Base64 } from './webhook-crypto.js';
|
|
43
|
+
const AGREEMENT_STATUS = {
|
|
44
|
+
PENDING: 'pending',
|
|
45
|
+
ACTIVE: 'active',
|
|
46
|
+
STOPPED: 'stopped',
|
|
47
|
+
EXPIRED: 'expired',
|
|
48
|
+
};
|
|
24
49
|
const PAYMENT_STATE = {
|
|
25
50
|
CREATED: 'pending',
|
|
26
51
|
AUTHORIZED: 'authorized',
|
|
@@ -80,14 +105,80 @@ function tomorrowIsoDate() {
|
|
|
80
105
|
return d.toISOString().slice(0, 10);
|
|
81
106
|
}
|
|
82
107
|
export function createVippsProvider(options) {
|
|
108
|
+
if (!options.accessToken && !(options.clientId && options.clientSecret)) {
|
|
109
|
+
throw new PaymentProviderError('vipps', 'createVippsProvider needs either accessToken, or both clientId and clientSecret');
|
|
110
|
+
}
|
|
83
111
|
const fetchImpl = options.fetch ?? defaultFetch();
|
|
84
112
|
const baseUrl = options.baseUrl ?? 'https://api.vipps.no';
|
|
85
|
-
|
|
113
|
+
const tokenUrl = options.tokenUrl ?? 'https://api.vipps.no/accesstoken/get';
|
|
114
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
115
|
+
const refreshMarginMs = (options.tokenRefreshMarginSeconds ?? 60) * 1000;
|
|
116
|
+
// Token cache, scoped to this provider instance (one per `subscriptionKey`
|
|
117
|
+
// + `merchantSerialNumber` pair, ordinarily). `inFlight` is the one thing
|
|
118
|
+
// that makes this concurrency-safe: N callers racing `resolveAccessToken`
|
|
119
|
+
// while the cache is empty or expired share the same `POST
|
|
120
|
+
// /accesstoken/get` promise instead of each firing their own -- assigned
|
|
121
|
+
// before any `await`, so there is no gap between "check the cache" and
|
|
122
|
+
// "start a refresh" for a second caller to land in. Cleared in `finally`,
|
|
123
|
+
// on success and on failure alike: a failed refresh must not wedge every
|
|
124
|
+
// later call behind a promise that has already rejected.
|
|
125
|
+
let cached = null;
|
|
126
|
+
let inFlight = null;
|
|
127
|
+
async function fetchAccessToken() {
|
|
128
|
+
const response = await fetchImpl(tokenUrl, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: {
|
|
131
|
+
client_id: options.clientId,
|
|
132
|
+
client_secret: options.clientSecret,
|
|
133
|
+
'Ocp-Apim-Subscription-Key': options.subscriptionKey,
|
|
134
|
+
'Merchant-Serial-Number': options.merchantSerialNumber,
|
|
135
|
+
},
|
|
136
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
137
|
+
});
|
|
138
|
+
const parsed = await response.json();
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new PaymentProviderError('vipps', `access token request failed: HTTP ${response.status}`, {
|
|
141
|
+
status: response.status,
|
|
142
|
+
raw: parsed,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const body = parsed;
|
|
146
|
+
if (!body.access_token) {
|
|
147
|
+
throw new PaymentProviderError('vipps', 'access token response has no "access_token" field', {
|
|
148
|
+
raw: parsed,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const expiresInSeconds = Number(body.expires_in ?? 0);
|
|
152
|
+
cached = {
|
|
153
|
+
token: body.access_token,
|
|
154
|
+
// A missing/unparsable expires_in caches nothing usable: expiresAtMs
|
|
155
|
+
// stays in the past, so the very next call refreshes again rather
|
|
156
|
+
// than reusing a token whose real lifetime is unknown.
|
|
157
|
+
expiresAtMs: Number.isFinite(expiresInSeconds) && expiresInSeconds > 0
|
|
158
|
+
? Date.now() + expiresInSeconds * 1000
|
|
159
|
+
: 0,
|
|
160
|
+
};
|
|
161
|
+
return cached.token;
|
|
162
|
+
}
|
|
163
|
+
async function resolveAccessToken() {
|
|
164
|
+
if (options.accessToken)
|
|
165
|
+
return options.accessToken; // v1 shape: caller-managed, never cached/refreshed here
|
|
166
|
+
if (cached && cached.expiresAtMs - refreshMarginMs > Date.now())
|
|
167
|
+
return cached.token;
|
|
168
|
+
if (!inFlight) {
|
|
169
|
+
inFlight = fetchAccessToken().finally(() => {
|
|
170
|
+
inFlight = null;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return inFlight;
|
|
174
|
+
}
|
|
175
|
+
async function resolveHeaders(idempotencyKey) {
|
|
176
|
+
const token = await resolveAccessToken();
|
|
86
177
|
const h = {
|
|
87
178
|
'Content-Type': 'application/json',
|
|
88
179
|
'Ocp-Apim-Subscription-Key': options.subscriptionKey,
|
|
89
180
|
'Merchant-Serial-Number': options.merchantSerialNumber,
|
|
90
|
-
Authorization: `Bearer ${
|
|
181
|
+
Authorization: `Bearer ${token}`,
|
|
91
182
|
};
|
|
92
183
|
if (idempotencyKey)
|
|
93
184
|
h['Idempotency-Key'] = idempotencyKey;
|
|
@@ -96,8 +187,9 @@ export function createVippsProvider(options) {
|
|
|
96
187
|
async function post(path, body, idempotencyKey) {
|
|
97
188
|
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
98
189
|
method: 'POST',
|
|
99
|
-
headers:
|
|
190
|
+
headers: await resolveHeaders(idempotencyKey),
|
|
100
191
|
body: JSON.stringify(body),
|
|
192
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
101
193
|
});
|
|
102
194
|
const parsed = await response.json();
|
|
103
195
|
if (!response.ok) {
|
|
@@ -111,7 +203,8 @@ export function createVippsProvider(options) {
|
|
|
111
203
|
async function get(path) {
|
|
112
204
|
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
113
205
|
method: 'GET',
|
|
114
|
-
headers:
|
|
206
|
+
headers: await resolveHeaders(undefined),
|
|
207
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
115
208
|
});
|
|
116
209
|
const parsed = await response.json();
|
|
117
210
|
if (!response.ok) {
|
|
@@ -122,6 +215,68 @@ export function createVippsProvider(options) {
|
|
|
122
215
|
}
|
|
123
216
|
return parsed;
|
|
124
217
|
}
|
|
218
|
+
/** Reads any error body a non-2xx response carries, but never throws on a
|
|
219
|
+
* body that fails to parse (used by `patch`/`del` below, whose *success*
|
|
220
|
+
* responses are documented as empty -- see each call site). */
|
|
221
|
+
async function readErrorBody(response) {
|
|
222
|
+
try {
|
|
223
|
+
return await response.json();
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** `PATCH /recurring/v3/agreements/{id}` (`stopRecurringAgreement`,
|
|
230
|
+
* `updateRecurringAgreement`) documents a `204 No Content` success
|
|
231
|
+
* response -- https://developer.vippsmobilepay.com/api/recurring/
|
|
232
|
+
* (fetched 2026-09-28) -- so this never calls `response.json()` on the
|
|
233
|
+
* success path, unlike `post`/`get` above. */
|
|
234
|
+
async function patch(path, body, idempotencyKey) {
|
|
235
|
+
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
236
|
+
method: 'PATCH',
|
|
237
|
+
headers: await resolveHeaders(idempotencyKey),
|
|
238
|
+
body: JSON.stringify(body),
|
|
239
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
240
|
+
});
|
|
241
|
+
if (!response.ok) {
|
|
242
|
+
throw new PaymentProviderError('vipps', `HTTP ${response.status}`, {
|
|
243
|
+
status: response.status,
|
|
244
|
+
raw: await readErrorBody(response),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** `DELETE /recurring/v3/agreements/{id}/charges/{chargeId}`
|
|
249
|
+
* (`cancelRecurringCharge`) documents a `202 Accepted`/`204 No Content`
|
|
250
|
+
* success response with no body -- same source and fetch date as `patch`
|
|
251
|
+
* above. */
|
|
252
|
+
async function del(path, idempotencyKey) {
|
|
253
|
+
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
254
|
+
method: 'DELETE',
|
|
255
|
+
headers: await resolveHeaders(idempotencyKey),
|
|
256
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
257
|
+
});
|
|
258
|
+
if (!response.ok) {
|
|
259
|
+
throw new PaymentProviderError('vipps', `HTTP ${response.status}`, {
|
|
260
|
+
status: response.status,
|
|
261
|
+
raw: await readErrorBody(response),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** Shared by `getRecurringAgreement` and the two `PATCH .../agreements`
|
|
266
|
+
* calls (`stopRecurringAgreement`, `updateRecurringAgreement`), neither of
|
|
267
|
+
* which gets its resulting status back in its own (empty) response body --
|
|
268
|
+
* both re-read the agreement the same way `getRecurringAgreement` always
|
|
269
|
+
* has. */
|
|
270
|
+
async function fetchAgreement(agreementReference) {
|
|
271
|
+
const agreement = await get(`/recurring/v3/agreements/${agreementReference}`);
|
|
272
|
+
return {
|
|
273
|
+
provider: 'vipps',
|
|
274
|
+
agreementReference: agreement.id,
|
|
275
|
+
status: (agreement.status ? AGREEMENT_STATUS[agreement.status] : undefined) ?? 'pending',
|
|
276
|
+
confirmationUrl: agreement.vippsConfirmationUrl,
|
|
277
|
+
raw: agreement,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
125
280
|
return {
|
|
126
281
|
name: 'vipps',
|
|
127
282
|
async createPayment(input) {
|
|
@@ -186,6 +341,39 @@ export function createVippsProvider(options) {
|
|
|
186
341
|
raw: payment,
|
|
187
342
|
};
|
|
188
343
|
},
|
|
344
|
+
async cancelPayment(providerReference, input = {}) {
|
|
345
|
+
// POST /epayment/v1/payments/{reference}/cancel --
|
|
346
|
+
// https://developer.vippsmobilepay.com/api/epayment/ (fetched
|
|
347
|
+
// 2026-09-28). Cancels a reserved, uncaptured payment; Vipps does not
|
|
348
|
+
// document an Idempotency-Key requirement here (unlike capture/refund),
|
|
349
|
+
// but this adapter still sends one, consistent with every other
|
|
350
|
+
// mutating call in this package.
|
|
351
|
+
const payment = await post(`/epayment/v1/payments/${providerReference}/cancel`, {}, input.idempotencyKey ?? `cnc:${providerReference}`);
|
|
352
|
+
// Same integrity check as capturePayment/refundPayment.
|
|
353
|
+
if (payment.reference !== providerReference) {
|
|
354
|
+
throw new PaymentProviderError('vipps', `cancel response reference "${payment.reference}" does not match the requested payment "${providerReference}"`, { raw: payment });
|
|
355
|
+
}
|
|
356
|
+
// The cancel response does not always carry an amount (neither
|
|
357
|
+
// `amount` nor `aggregate.authorizedAmount`) -- when it does not,
|
|
358
|
+
// re-read the payment rather than assume Vipps left it as-is. If the
|
|
359
|
+
// re-read also has no amount, a caller must never read a made-up
|
|
360
|
+
// amount as if Vipps reported it: a defaulted 0/NOK is simply wrong
|
|
361
|
+
// for a EUR or DKK merchant. Throw instead, with a distinct `code` so
|
|
362
|
+
// a caller can tell "cancelled, amount unknown, re-read later" apart
|
|
363
|
+
// from a plain cancel failure.
|
|
364
|
+
const resolved = (payment.amount ?? payment.aggregate?.authorizedAmount)
|
|
365
|
+
? payment
|
|
366
|
+
: await get(`/epayment/v1/payments/${providerReference}`);
|
|
367
|
+
const knownAmount = resolved.amount ?? resolved.aggregate?.authorizedAmount;
|
|
368
|
+
if (!knownAmount) {
|
|
369
|
+
throw new PaymentProviderError('vipps', `cancel response for payment ${providerReference} carries no amount to report, even after re-reading the payment`, { raw: resolved, code: 'cancel_amount_unknown' });
|
|
370
|
+
}
|
|
371
|
+
const fallback = {
|
|
372
|
+
value: knownAmount.value,
|
|
373
|
+
currency: knownAmount.currency.toUpperCase(),
|
|
374
|
+
};
|
|
375
|
+
return toPaymentResult(resolved, fallback);
|
|
376
|
+
},
|
|
189
377
|
async createRecurringAgreement(input) {
|
|
190
378
|
// pricing.type LEGACY (fixed amount) vs VARIABLE (suggestedMaxAmount,
|
|
191
379
|
// no fixed amount): https://developer.vippsmobilepay.com/api/recurring/
|
|
@@ -247,6 +435,74 @@ export function createVippsProvider(options) {
|
|
|
247
435
|
raw: charge,
|
|
248
436
|
};
|
|
249
437
|
},
|
|
438
|
+
async getRecurringAgreement(agreementReference) {
|
|
439
|
+
return fetchAgreement(agreementReference);
|
|
440
|
+
},
|
|
441
|
+
async stopRecurringAgreement(agreementReference, input = {}) {
|
|
442
|
+
// PATCH /recurring/v3/agreements/{id}, { status: 'STOPPED' } --
|
|
443
|
+
// https://developer.vippsmobilepay.com/api/recurring/ (fetched
|
|
444
|
+
// 2026-09-28). Idempotent on Vipps' side: stopping an already-STOPPED
|
|
445
|
+
// agreement returns 204 with no further effect. The response has no
|
|
446
|
+
// body, so this reads the resulting status back the same way
|
|
447
|
+
// `getRecurringAgreement` always has.
|
|
448
|
+
await patch(`/recurring/v3/agreements/${agreementReference}`, { status: 'STOPPED' }, input.idempotencyKey ?? `stop:${agreementReference}`);
|
|
449
|
+
return fetchAgreement(agreementReference);
|
|
450
|
+
},
|
|
451
|
+
async updateRecurringAgreement(agreementReference, input) {
|
|
452
|
+
// Same PATCH endpoint as stopRecurringAgreement. The update body's
|
|
453
|
+
// `pricing` is `UpdateAgreementPricingRequest` in Vipps' own Recurring
|
|
454
|
+
// v3 OpenAPI spec (recurring-swagger-id.yaml, "Recurring Payments
|
|
455
|
+
// Merchant API" 3.2.3, fetched 2026-09-29 from
|
|
456
|
+
// https://developer.vippsmobilepay.com/redocusaurus/recurring-swagger-id.yaml,
|
|
457
|
+
// linked from https://developer.vippsmobilepay.com/api/recurring/):
|
|
458
|
+
//
|
|
459
|
+
// PricingUpdateRequest:
|
|
460
|
+
// title: UpdateAgreementPricingRequest
|
|
461
|
+
// type: object
|
|
462
|
+
// properties:
|
|
463
|
+
// amount: { type: integer, ... }
|
|
464
|
+
// suggestedMaxAmount: { type: integer, ... }
|
|
465
|
+
//
|
|
466
|
+
// No `currency`, no `type` -- unlike `PricingRequestV3` (create), which
|
|
467
|
+
// requires both. An agreement's currency is fixed at creation and
|
|
468
|
+
// cannot be changed through this endpoint, so this checks the
|
|
469
|
+
// agreement's current currency (from a GET, `pricing.currency` on
|
|
470
|
+
// `AgreementResponseV3`) before sending anything, and refuses a
|
|
471
|
+
// currency change with a clear `PaymentProviderError` instead of
|
|
472
|
+
// sending a field Vipps' spec does not list and getting back a 400.
|
|
473
|
+
// This is the LEGACY (fixed-price) `amount` field; a VARIABLE
|
|
474
|
+
// agreement's `suggestedMaxAmount` cap is a separate, payer-driven
|
|
475
|
+
// value this method does not touch -- see
|
|
476
|
+
// `UpdateRecurringAgreementInput`'s doc comment. Vipps itself refuses
|
|
477
|
+
// (400) a price change on a stopped agreement; this adapter does not
|
|
478
|
+
// duplicate that check. It does refuse anything but a LEGACY
|
|
479
|
+
// agreement below, because Vipps applies `pricing.amount` only to a
|
|
480
|
+
// LEGACY one -- a PATCH against a VARIABLE or FLEXIBLE agreement
|
|
481
|
+
// returns 204 and changes nothing, which would otherwise read to this
|
|
482
|
+
// method's caller as a silent no-op success.
|
|
483
|
+
const nextCurrency = input.amount.currency.toUpperCase();
|
|
484
|
+
const current = await get(`/recurring/v3/agreements/${agreementReference}`);
|
|
485
|
+
if (current.pricing?.type !== 'LEGACY') {
|
|
486
|
+
throw new PaymentProviderError('vipps', `agreement ${agreementReference} has pricing.type "${current.pricing?.type ?? 'unknown'}"; updateRecurringAgreement's amount can only be updated on a LEGACY agreement`, { raw: current });
|
|
487
|
+
}
|
|
488
|
+
const currentCurrency = current.pricing?.currency?.toUpperCase();
|
|
489
|
+
if (currentCurrency && currentCurrency !== nextCurrency) {
|
|
490
|
+
throw new PaymentProviderError('vipps', `agreement ${agreementReference} is priced in ${currentCurrency}; updateRecurringAgreement cannot change its currency to ${nextCurrency} (Vipps’ update-agreement pricing body takes amount only)`, { raw: current });
|
|
491
|
+
}
|
|
492
|
+
await patch(`/recurring/v3/agreements/${agreementReference}`, { pricing: { amount: input.amount.value } }, input.idempotencyKey ?? `upd:${agreementReference}:${input.amount.value}:${nextCurrency}`);
|
|
493
|
+
return fetchAgreement(agreementReference);
|
|
494
|
+
},
|
|
495
|
+
async cancelRecurringCharge(agreementReference, chargeReference, input = {}) {
|
|
496
|
+
// DELETE /recurring/v3/agreements/{agreementId}/charges/{chargeId} --
|
|
497
|
+
// https://developer.vippsmobilepay.com/api/recurring/ (fetched
|
|
498
|
+
// 2026-09-28). Permitted for a PENDING/DUE/RESERVED charge; a
|
|
499
|
+
// PARTIALLY_CAPTURED charge has its remaining funds released back to
|
|
500
|
+
// the payer. Returns 202/204 with no body -- the caller learns the
|
|
501
|
+
// outcome via the resulting `recurring.charge-canceled.v1` webhook
|
|
502
|
+
// (`charge.canceled`, already wired to `applyWebhookEvent`), the same
|
|
503
|
+
// way a created charge's own outcome already arrives.
|
|
504
|
+
await del(`/recurring/v3/agreements/${agreementReference}/charges/${chargeReference}`, input.idempotencyKey ?? `dch:${chargeReference}`);
|
|
505
|
+
},
|
|
250
506
|
};
|
|
251
507
|
}
|
|
252
508
|
const WEBHOOK_EVENT_NAME = {
|
|
@@ -259,6 +515,42 @@ const WEBHOOK_EVENT_NAME = {
|
|
|
259
515
|
EXPIRED: 'payment.expired',
|
|
260
516
|
ABORTED: 'payment.cancelled',
|
|
261
517
|
};
|
|
518
|
+
/** The Recurring API's own webhook event catalogue -- a different namespace
|
|
519
|
+
* from the ePayment `name` field `WEBHOOK_EVENT_NAME` above maps.
|
|
520
|
+
* https://developer.vippsmobilepay.com/docs/APIs/webhooks-api/events/
|
|
521
|
+
* (fetched 2026-09-24 via a documentation-summarizing tool, the same
|
|
522
|
+
* caveat docs/adr/0004 already carries for the ePayment side -- not
|
|
523
|
+
* checked against one real signed Vipps delivery). */
|
|
524
|
+
const RECURRING_EVENT_TYPE = {
|
|
525
|
+
'recurring.agreement-activated.v1': 'agreement.activated',
|
|
526
|
+
'recurring.agreement-rejected.v1': 'agreement.rejected',
|
|
527
|
+
'recurring.agreement-stopped.v1': 'agreement.stopped',
|
|
528
|
+
'recurring.agreement-expired.v1': 'agreement.expired',
|
|
529
|
+
'recurring.charge-reserved.v1': 'charge.reserved',
|
|
530
|
+
'recurring.charge-captured.v1': 'charge.captured',
|
|
531
|
+
'recurring.charge-canceled.v1': 'charge.canceled',
|
|
532
|
+
'recurring.charge-refunded.v1': 'charge.refunded',
|
|
533
|
+
'recurring.charge-failed.v1': 'charge.failed',
|
|
534
|
+
'recurring.charge-creation-failed.v1': 'charge.failed',
|
|
535
|
+
};
|
|
536
|
+
/** A Recurring event names one agreement always, and a charge only for a
|
|
537
|
+
* `charge.*` type -- `paymentReference` (which `store.ts` matches against
|
|
538
|
+
* `payment_charges.provider_charge_id`) is empty for an `agreement.*` event,
|
|
539
|
+
* which has none. No documented event id field (unlike the ePayment side's
|
|
540
|
+
* `idempotencyKey`), so `eventId` is synthesized from what is documented,
|
|
541
|
+
* the same fallback shape the ePayment path below already uses. */
|
|
542
|
+
function normalizeRecurringEvent(event) {
|
|
543
|
+
const amount = event.amountCaptured ?? event.amountCanceled ?? event.amountRefunded ?? event.amount;
|
|
544
|
+
return {
|
|
545
|
+
provider: 'vipps',
|
|
546
|
+
type: RECURRING_EVENT_TYPE[event.eventType] ?? 'unknown',
|
|
547
|
+
eventId: `${event.agreementId ?? 'unknown'}:${event.chargeId ?? ''}:${event.eventType}:${event.occurred ?? ''}`,
|
|
548
|
+
paymentReference: event.chargeId ?? '',
|
|
549
|
+
agreementReference: event.agreementId,
|
|
550
|
+
amount: amount ? { value: amount.value, currency: amount.currency.toUpperCase() } : undefined,
|
|
551
|
+
raw: event,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
262
554
|
/**
|
|
263
555
|
* Verifies a Vipps MobilePay webhook request, by hand, against the
|
|
264
556
|
* Azure-API-Management-style HMAC scheme documented at
|
|
@@ -314,6 +606,12 @@ export function verifyVippsWebhook(rawBody, requestHeaders, webhookSecret, metho
|
|
|
314
606
|
catch {
|
|
315
607
|
throw new WebhookVerificationError('vipps', 'payload is not valid JSON');
|
|
316
608
|
}
|
|
609
|
+
// Two payload shapes share this endpoint's signature scheme: ePayment's
|
|
610
|
+
// (`name`/`success`, handled below) and the Recurring API's (`eventType`,
|
|
611
|
+
// agreement/charge events -- see normalizeRecurringEvent's doc comment).
|
|
612
|
+
if (event.eventType) {
|
|
613
|
+
return normalizeRecurringEvent(event);
|
|
614
|
+
}
|
|
317
615
|
const mapped = event.name ? WEBHOOK_EVENT_NAME[event.name] : undefined;
|
|
318
616
|
const type = event.success === false ? 'payment.failed' : (mapped ?? 'unknown');
|
|
319
617
|
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.4.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
|
+
}
|