@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 +21 -0
- package/README.md +127 -0
- package/dist/http.d.ts +23 -0
- package/dist/http.js +9 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +3 -0
- package/dist/provider.d.ts +17 -0
- package/dist/provider.js +1 -0
- package/dist/stripe.d.ts +43 -0
- package/dist/stripe.js +275 -0
- package/dist/types.d.ts +133 -0
- package/dist/types.js +32 -0
- package/dist/vipps.d.ts +55 -0
- package/dist/vipps.js +314 -0
- package/dist/webhook-crypto.d.ts +16 -0
- package/dist/webhook-crypto.js +35 -0
- package/package.json +41 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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,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
|
+
}
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/stripe.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/vipps.d.ts
ADDED
|
@@ -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
|
+
}
|