@wtfalch/payments
v0.3.0
Published
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.
Readme
@wtfalch/payments
One PaymentProvider interface -- create a payment, capture, refund, set up
and charge a recurring agreement -- over Stripe and Vipps MobilePay, plus a
verify*Webhook function per provider that turns a signed webhook request
into one normalized event shape. Holds no card data: plain HTTP to each
provider's REST API, zero runtime dependencies.
Install
pnpm add @wtfalch/paymentsUse
Both adapters implement the same PaymentProvider interface. The caller
resolves each provider's credential (from @wtfalch/keys, in the estate's
case) and passes it in; this package never fetches or stores one.
import { createStripeProvider, createVippsProvider } from '@wtfalch/payments';
const stripe = createStripeProvider({ secretKey }); // sk_...
// Either an already-valid access token (0.2.x shape, never refreshed here)...
const vipps = createVippsProvider({ subscriptionKey, merchantSerialNumber, accessToken });
// ...or let the adapter fetch and cache its own (recommended: it refreshes
// before expiry and shares one in-flight refresh across concurrent calls).
const vipps2 = createVippsProvider({ subscriptionKey, merchantSerialNumber, clientId, clientSecret });
const payment = await stripe.createPayment({
reference: 'order-123',
amount: { value: 1000, currency: 'NOK' }, // minor units: 1000 = 10.00 NOK
returnUrl: 'https://example.com/orders/123/return',
});
// payment.providerReference -- pass to capturePayment/refundPayment
// payment.raw.client_secret -- Stripe: confirm with Stripe.js/Elements
// payment.redirectUrl -- Vipps: send the payer hereawait stripe.capturePayment(payment.providerReference); // full capture
await stripe.refundPayment(payment.providerReference, { reason: 'requested_by_customer' });Recurring agreements
Thin provider mechanics only -- prices, plans and dunning are
@wtfalch/billing's job, not this package's:
const agreement = await vipps.createRecurringAgreement({
reference: 'agreement-123',
amount: { value: 29900, currency: 'NOK' },
productName: 'Pro plan',
returnUrl: 'https://example.com/agreements/123/return',
managementUrl: 'https://example.com/account/subscription',
});
// agreement.confirmationUrl -- send the payer here to approve it
await vipps.chargeRecurringAgreement(agreement.agreementReference, {
amount: { value: 29900, currency: 'NOK' },
description: 'March invoice',
});
// Read the agreement's current status fresh from the provider (e.g. after
// the payer approves it, or on a schedule) -- what refreshAgreementStatus
// below calls under the hood.
await vipps.getRecurringAgreement(agreement.agreementReference);For Stripe, createRecurringAgreement creates a SetupIntent
(agreement.clientSecret -- confirm with Stripe.js) and
chargeRecurringAgreement looks up the payment method it saved and charges
it off-session. getRecurringAgreement reads that same SetupIntent back.
Store
A provider-neutral Postgres store for the agreement/charge rows a caller would otherwise keep in its own schema -- see docs/adr/0006-payment-store.md for the full design and its concurrency-safety argument.
import { migrate, createOrGetAgreement, createOrGetCharge, refreshAgreementStatus, applyWebhookEvent } from '@wtfalch/payments';
// Once, at startup or in a migration step. `db` must be a single connection
// (not a pool) -- see db.ts's doc comment.
await migrate(db);
// Idempotent: a second call with the same externalReference returns the
// same row and never calls the provider again -- but only if the terms
// match. A retry with a different amount/currency (or, for a charge, a
// different agreementId) throws PaymentStoreError('mismatched_retry', ...)
// instead of silently returning the first call's row.
const agreement = await createOrGetAgreement(db, vipps, {
externalReference: subscription.id, // the caller's own key
amount: { value: 29900, currency: 'NOK' },
productName: 'Pro plan',
returnUrl: 'https://example.com/agreements/return',
});
// agreement.confirmationUrl -- send the payer here
// After the payer approves it (e.g. on their return, or on a schedule):
await refreshAgreementStatus(db, vipps, agreement.id);
// Refuses with PaymentStoreError('agreement_not_active', ...) unless the
// agreement's stored status is "active".
const charge = await createOrGetCharge(db, vipps, {
agreementId: agreement.id,
externalReference: billingCharge.id,
amount: { value: 29900, currency: 'NOK' },
description: 'March invoice',
});
// In the webhook handler, after verify*Webhook:
const applied = await applyWebhookEvent(db, event);
if (applied.matched === 'charge' && applied.changed && applied.charge?.status === 'captured') {
// react to the newly captured charge
}Status transitions are monotonic: applyWebhookEvent and
refreshAgreementStatus gate every write through an explicit per-entity
allowed-predecessor table, so a delayed, out-of-order webhook delivery (or a
stale provider read) can never move a charge or agreement's status
backwards, and a terminal status (refunded/canceled/failed;
stopped/expired) never leaves. A rejected transition reports
{ changed: false, ignored: 'stale' } rather than being applied silently --
this is what keeps a caller's "invoice when changed && captured" safe
under redelivery-without-ordering, not merely defensive. Two concurrent
deliveries of the same event report exactly one changed: true.
db is a single-connection Queryable ({ query(text, values?) }) -- a
postgres.js pool's sql.reserve(), not the pool itself; see db.ts's doc
comment for why every mutating store function needs this.
Webhooks
import { verifyStripeWebhook, verifyVippsWebhook } from '@wtfalch/payments';
// Stripe: the raw request body and the Stripe-Signature header, unparsed.
const event = verifyStripeWebhook(rawBody, req.headers['stripe-signature'], webhookSecret);
// Vipps: the raw body plus the four headers the Azure-APIM HMAC scheme signs,
// and the request's own method + path (see the module doc comment in
// src/vipps.ts for the exact algorithm and its primary sources).
const event = verifyVippsWebhook(
rawBody,
{ authorization: req.headers.authorization, xMsDate: req.headers['x-ms-date'], xMsContentSha256: req.headers['x-ms-content-sha256'], host: req.headers.host },
webhookSecret,
req.method,
req.url,
);Both throw WebhookVerificationError on a bad signature, a tampered
payload, or a timestamp outside the replay tolerance (default 5 minutes) --
never return a partially-trusted event. event.type is one of a closed set
(payment.created | authorized | captured | refunded | cancelled | failed |
expired | unknown); an event type this package does not yet recognise
normalizes to unknown rather than throwing, so a new provider event is
forward-compatible.
Errors
PaymentProviderError-- a provider API call failed (HTTP error, network error, or an unparsable response). Never thrown for "the payment was declined"; that is a normalPaymentResult/RefundResultwith afailedstatus.WebhookVerificationError-- a webhook failed to verify. The caller must reject the request, never act on the event.
Tests
From packages/payments:
pnpm testAdapter and webhook tests run against recorded/fake fixtures
(src/fixtures/{stripe,vipps}) and a fake fetch (src/test/fake-fetch.ts)
-- never a live provider call, never a real key. Webhook verification tests
build their own valid signature independently of the code under test (see
*-webhook.test.ts), then check that a tampered payload, a tampered
signature, a wrong secret, a wrong request path (Vipps), and a stale
timestamp are all rejected.
Store tests (store.test.ts, migrate.test.ts) run against PGlite in
memory by default and against a real Postgres with TEST_DATABASE_URL set
-- against a throwaway container, never a shared one:
docker run -d --rm --name payments-pg -e POSTGRES_PASSWORD=postgres -p 5655:5432 postgres:16-alpine
TEST_DATABASE_URL=postgres://postgres:[email protected]:5655/postgres pnpm test
docker stop payments-pgKnown gaps
- The exact
stateenum on a Vipps ePayment response was not confirmed from primary docs at build time (seesrc/vipps.ts's module doc comment).derivePaymentStatusfalls back to the documentedaggregate.*Amountfields whenstateis absent or unrecognised, but this adapter has not been exercised against a real Vipps sandbox response. (The Recurring agreementstatusenum the store depends on is confirmed -- docs/adr/0006.) - No webhook payload shape (ePayment's
name/success, or the Recurring API'seventType) has been checked against a real, signed Vipps delivery -- see docs/adr/0004's "What is not yet confirmed". - Neither Recurring event's own event-id field is documented, so
eventIdis synthesized from the fields that are (seevipps.ts'snormalizeRecurringEvent) -- fine for logging, not guaranteed collision-free the way a real event id would be.
Do all of the above before relying on this adapter for anything that pays out money.
