npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/payments

Use

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 here
await 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 normal PaymentResult/RefundResult with a failed status.
  • WebhookVerificationError -- a webhook failed to verify. The caller must reject the request, never act on the event.

Tests

From packages/payments:

pnpm test

Adapter 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-pg

Known gaps

  • The exact state enum on a Vipps ePayment response was not confirmed from primary docs at build time (see src/vipps.ts's module doc comment). derivePaymentStatus falls back to the documented aggregate.*Amount fields when state is absent or unrecognised, but this adapter has not been exercised against a real Vipps sandbox response. (The Recurring agreement status enum the store depends on is confirmed -- docs/adr/0006.)
  • No webhook payload shape (ePayment's name/success, or the Recurring API's eventType) 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 eventId is synthesized from the fields that are (see vipps.ts's normalizeRecurringEvent) -- 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.