sdxc

[ Operations ]

@sdxc/billing

Vendor-neutral billing providers and a webhook endpoint that verifies deliveries

npm add @sdxc/billing remix
pnpm add @sdxc/billing remix
yarn add @sdxc/billing remix
bun add @sdxc/billing remix
Used by
uptime, auth-saas, reader, books

Vendor-neutral billing providers and a webhook endpoint that verifies deliveries.

Code is written against one Billing contract and its models (Customer, Subscription, Order, Checkout, EntitlementState), so a vendor's name appears in one import and one construction site. Every method answers a Result, and purchases are hosted links: checkout and portal answer a URL, so no card data passes through your code.

Installation

npm add @sdxc/billing

Calls answer a Result from @sdxc/result. The middleware and the webhook endpoint mount into a remix router, and ./conformance registers Vitest tests.

Usage

Construct a provider

Build a provider once at module scope; the constructor makes no network call. Products, meters and features are your own slugs mapped to the platform's ids, so call sites say "pro" and reads answer productSlug: "pro".

import type { Billing } from "@sdxc/billing";

import { PolarBilling } from "@sdxc/billing/providers/polar";

export let billing: Billing = new PolarBilling({
	accessToken: () => readSecret("POLAR_ACCESS_TOKEN"),
	webhookSecret: () => readSecret("POLAR_WEBHOOK_SECRET"),
	products: { pro: "019...", team: "019..." },
	meters: { pings: "019..." },
	features: { reports: "019..." },
});

Annotate the export as Billing: code stays written against the contract, and supports() can narrow the optional groups.

Open a hosted checkout

import { isFailure } from "@sdxc/result";
import { redirect } from "remix/response/redirect";

let checkout = await billing.checkouts.create({
	product: "pro",
	customer: { externalId: userId },
	returnTo: "https://example.com/billing/thanks",
	idempotencyKey: `checkout_${attemptId}`,
});

if (isFailure(checkout) || checkout.data.url === null) {
	return new Response(null, { status: 502 });
}

return redirect(checkout.data.url, 303);

A checkout with no url is no longer payable. The return route calls billing.checkouts.finish(id) to read what the session produced.

Publish the provider on the router

import billingMiddleware from "@sdxc/billing/middleware";
import { isFailure } from "@sdxc/result";
import { createRouter } from "remix/router";

let router = createRouter({ middleware: [billingMiddleware({ provider: billing })] });

router.get("/billing/subscriptions", async (context) => {
	let page = await context.billing.subscriptions.list({
		customer: { externalId: userId },
		status: ["active", "trialing"],
	});
	if (isFailure(page)) return new Response(null, { status: 502 });
	return Response.json(page.data.items);
});

Receive webhooks

import { BillingWebhook } from "@sdxc/billing";

router.post(
	"/webhooks/billing",
	new BillingWebhook(
		billing,
		{
			async "order.paid"(event) {
				await fulfil(event.order.id, event.order.customerEmail);
			},
		},
		{ store: deliveries },
	),
);

The endpoint verifies the signature, skips a delivery the store already processed, and calls the handler keyed by the event's type.

API

EntryExports
@sdxc/billingBilling, the models, BillingError, supports(), BillingWebhook, helpers
@sdxc/billing/middlewarebilling() (default), requireEntitlement(), readEntitlements()
@sdxc/billing/middleware/clibilling() (default) for an @sdxc/cli command line
@sdxc/billing/providers/polarPolarBilling
@sdxc/billing/providers/stripeStripeBilling
@sdxc/billing/providers/mercado-pagoMercadoPagoBilling
@sdxc/billing/providers/memoryMemoryBilling, an in-memory platform for tests
@sdxc/billing/conformanceThe Vitest suites every provider passes

Billing

The provider contract: one object grouping every operation by resource, shared by routes and jobs. connection names the configured account, the value to store beside every provider id you keep; native is the underlying client (typed unknown) for endpoints the contract leaves out. portal, discounts, usage and meters are optional groups, present only where the platform has them.

Every method answers Promise<Result<T, BillingError>>, and a find* that matches nothing answers a not_found failure:

customers.create({ email, externalId, name?, metadata? })
customers.update(customer: CustomerRef, { email?, name?, externalId?, metadata? })
customers.find(customer: CustomerRef)
customers.findByEmail(email: string)
customers.list({ email?, limit?, cursor? }?)

catalog.find(slug: string)
catalog.list({ archived?, limit?, cursor? }?)

checkouts.create(input: CreateCheckoutInput)
checkouts.find(id: string)
checkouts.finish(id: string)

subscriptions.find(id: string)
subscriptions.list({ customer?, product?, status?, limit?, cursor? }?)
subscriptions.cancel(id: string, { atPeriodEnd? }?)

entitlements.of(customer: CustomerRef)

orders.find(id: string)
orders.list({ customer?, product?, subscription?, limit?, cursor? }?)

portal.create({ customer, returnTo? })

discounts.find(id: string)
discounts.findByCode(code: string)
discounts.list({ product?, limit?, cursor? }?)

usage.ingest(events: readonly UsageEvent[])
usage.list({ customer?, name?, from?, to?, limit?, cursor? }?)

meters.quantities({ meter, customer?, from, to, interval })
  • customers.create requires externalId, your own subject id and the join key; a taken one answers conflict. update with externalId adopts a platform customer that has none.

  • The catalog and discounts are read-only: products, prices and coupons are created in the platform's dashboard. findByCode turns a typed code into the id checkouts.create takes.

  • CreateCheckoutInput takes product plus optional customer, email, returnTo, discount, quantity, metadata, allowDiscountCodes and idempotencyKey. Without customer the hosted page collects the buyer. idempotencyKey returns the same session on platforms with idempotency and is recorded on the session elsewhere.

  • Subscriptions are created by a completed checkout. cancel ends one now, or at period end with atPeriodEnd: true, which a platform that can only cancel immediately answers unsupported.

  • entitlements.of answers everything a customer holds now, the snapshot to write into your own tables.

  • usage.ingest answers { accepted }, counts a repeated externalId once and chunks to the platform's limit itself. A meter read names its whole window: from, to and interval.

  • A list skips a row naming a product the provider was not configured with; only cursor === null ends a list.

webhooks holds the three questions the endpoint asks: verify(request, rawBody) answers a Promise<boolean> (false while the signing secret is unset or unreadable), reference(request, rawBody) answers the WebhookReference { deliveryId, object } or null, and event(request, rawBody) answers the normalized BillingEvent.

BillingError

The failure inside every Result, with code, connection, the platform's providerCode, retryable and retryAfter (seconds, or null). new BillingError(message, options) takes the same fields, for a provider you write.

BillingErrorCode is not_found, invalid_request, unauthenticated, forbidden, conflict, rate_limited, invalid_response, unsupported, not_implemented or unknown. rate_limited is retryable by default. unknown is a timeout or 5xx that may have taken effect, so it is never retryable and is resolved by a reconciliation read. invalid_response is a 2xx the models cannot express, unsupported is something the platform cannot do, and not_implemented is something this provider has not done.

supports(billing, capability): boolean

Narrows an optional group to present, so billing.meters type-checks inside the branch. OPTIONAL_CAPABILITIES lists the four: "discounts", "meters", "portal", "usage".

minorUnitDigits(currency: Currency): number

Digits after the decimal separator for an ISO 4217 code in any case: 0 for JPY and CLP, 3 for KWD, 2 by default. Every Money amount is in minor units, so divide by 10 ** digits.

let { amount, currency } = order.total;
let major = amount / 10 ** minorUnitDigits(currency); // ¥5,000 stays 5000

DEFAULT_PAGE_SIZE

20, the page size of a list that names no limit, on every provider.

BillingWebhook

new BillingWebhook(provider, handlers, options?) builds the endpoint; the instance mounts as a route action, and endpoint.handler is its bound RequestHandler. handlers maps an event type to (event, context) => void | Promise<void>, typed per event. With options.store, a delivery already processed answers 200 without running anything, and every delivery is recorded with its valid verdict before it is trusted.

An unverified delivery answers 401. Everything else answers 200, except a handler throwing an error options.retry(error, event) accepts, which answers 503 so the platform redelivers; retry defaults to a BillingError marked retryable. Only a delivery whose handler completed (or that had no handler) is marked processed.

WebhookStore and MemoryWebhookStore

The table deliveries are kept in, written by you: find(id), record(delivery) and markProcessed(id). A WebhookDelivery is { id, type, payload, valid, processed }, with payload the exact body received. MemoryWebhookStore keeps them in process and exposes deliveries in arrival order, for tests.

billing(options) from ./middleware

The default export; publishes the provider as context.billing, typed on RequestContext once the module is imported. options.provider is a Billing or (context) => Billing, resolved once per request. options.entitlements is an EntitlementSource: a function of the context answering an EntitlementSnapshot, null or a promise of either, read from your own projection. The provider is published under the CurrentBilling context key, exported here and from ./middleware/cli, so ctx.get(CurrentBilling) reads it in a route or a command.

billing(options) from ./middleware/cli

The default export; publishes the provider as ctx.billing on an @sdxc/cli command, for granting an entitlement or looking up a customer by hand. options.provider is a Billing or (ctx) => Billing, resolved once per run.

import billing from "@sdxc/billing/middleware/cli";
import { createCLI } from "@sdxc/cli";

let cli = createCLI({ name: "app", commands, middleware: [billing({ provider: polar })] });

requireEntitlement(feature, options?) from ./middleware

Route middleware admitting a request only when the projection grants feature, then publishing it as context.entitlements (or context.get(Entitlements)). options.onDenied, given the context and the feature, builds the denial response, 403 by default. It throws when billing() ran without entitlements.

readEntitlements(context) from ./middleware

Answers Result<EntitlementSnapshot | null, Error> from the entitlements source, read at most once per request and shared with every guard. A missing source or a rejected read is a failure.

PolarBilling from ./providers/polar

Polar, with every group including the four optional ones; native is the API client.

  • accessToken: the organization access token, a Secret

  • webhookSecret?: the endpoint's signing secret as Polar issued it, a Secret

  • products?, meters?, features?: Polar product, meter and benefit ids by your own slugs

  • connection?: defaults to "polar"

  • sandbox?: bill against Polar's sandbox

StripeBilling from ./providers/stripe

Stripe, with portal and no other optional group. customers.list, orders.find and orders.list answer not_implemented.

  • secretKey: a Secret

  • catalog: { product, price } Stripe ids by your own slugs (StripeCatalogEntry)

  • webhookSecret?: a Secret

  • meters?: Stripe meter ids by your own slugs

  • portalConfiguration?: the portal configuration sessions open against

  • externalIdKey?: the customer metadata key holding your id, defaults to "external_id"

  • connection?: defaults to "stripe"; baseURL?: a URL, defaults to Stripe's v1 API

MercadoPagoBilling from ./providers/mercado-pago

Mercado Pago, a payment processor, with no optional group. Tax, invoicing and disputes stay with you.

  • accessToken: a Secret

  • products?: by your own slugs, { kind: "recurring", plan } naming a stored plan, or { kind: "one_time", name, price } priced here; both take features?

  • webhookSecret?: a Secret

  • notificationURL?: where the platform posts deliveries for checkouts this instance opens

  • backURLs?: { success?, failure?, pending? } (MercadoPagoBackURLs)

  • connection?: defaults to "mercado-pago"

customers.find({ externalId }), cancel with atPeriodEnd: true, and a checkout with discount or allowDiscountCodes: true answer unsupported. merchantOrder(id) reads the merchant order behind a hosted checkout, with every payment it collected.

MemoryBilling from ./providers/memory

A complete platform in memory, with every group, passing the conformance suites.

  • catalog?: MemoryProductSeed by slug: amount, plus optional currency ("usd"), name, description, interval (recurring), meter (metered), features, credits (meter balances granted) and archived

  • discounts?: MemoryDiscountSeed[], each with optional id, code, name, percentage or amount and currency, products, maxRedemptions, redemptions, startsAt, endsAt

  • faults?: MemoryFaults, failures armed from the start

  • webhookSecret?: the base64 secret emitted deliveries are signed with

  • connection?: defaults to "memory"

checkouts.finish completes an open session, creating the order and any subscription. Beyond the contract it adds:

  • seed(catalog): adds products, replacing any with the same slug.

  • fail(target, code = "unknown"): arms a failure on a group ("customers") or a method ("subscriptions.list", a MemoryFaultTarget); a method-level fault wins.

  • heal(target?): disarms one target, or all.

  • with(overrides): a Billing with the named groups replaced (MemoryBillingOverrides); an optional group set to undefined is absent.

  • webhooks.emit(event): signs a MemoryEmitEvent (a BillingEventPayload plus optional id) and answers a MemoryDelivery { request, body, headers, event }; reusing an id models a redelivery.

./conformance

conformance(options) registers the required core; portalConformance, discountConformance, usageConformance and meterConformance cover each optional group, and capabilityConformance checks that every declared group answers a real call and every undeclared one is absent.

ConformanceOptions:

  • name: labels the suites

  • create(): builds the provider under test, once per test

  • subscription: a recurring catalog product, as a ConformanceProduct ({ slug, amount, currency, priceId? })

  • zeroDecimal: a product priced in a currency with no minor units, such as JPY

  • meter?: required when the provider declares meters

  • missing?: well-formed ids holding no record (ConformanceMissingIds), a fresh UUID by default

  • email?(): builds an unused address, unique per call by default

Types

  • Models: Customer, Product, Price, Checkout, Subscription, Order, Discount, PortalSession, UsageRecord, MeterQuantity, UsageIngest. Each carries providerData, the platform's own payload, worth storing beside your columns.

  • EntitlementState: the customer's ids, products and features by your own slugs, meters, subscriptions, readAt and providerData, with MeterBalance { meter, credited, consumed, balance } and EntitlementSubscription carrying the current period.

  • Money is { amount, currency } in integer minor units; Cost is the same with amount as a decimal string, for per-unit usage costs too small for a number. Currency is the ISO code.

  • CustomerRef is { id } or { externalId }. Page<T> is { items, cursor }.

  • UsageEvent is { name, customer, externalId?, timestamp?, metadata?, cost? }.

  • BillingEvent is { id, raw } plus a BillingEventPayload: customer.created, customer.updated, checkout.completed, subscription.activated, subscription.updated, subscription.canceled, subscription.revoked, order.paid, order.refunded, or unrecognized with the platform's providerType. BillingEventType and BillingEventOf<T> name them; BillingWebhookHandler<T>, BillingWebhookHandlers and BillingWebhookOptions type the endpoint.

  • Secret is string | (() => string | Promise<string>). The function is called on first use and its answer kept; a rejection is asked again next time.

  • EntitlementSnapshot (./middleware) is { products, features }; an EntitlementState satisfies it.

  • Status and kind unions: SubscriptionStatus, CheckoutStatus, PriceKind, DiscountKind, BillingInterval. Input and query types: CreateCustomerInput, UpdateCustomerInput, CreateCheckoutInput, CreatePortalInput, List*Query, MeterQuantityQuery, and each group's *Api interface.

Pattern: Keeping An Entitlement Projection In Sync

Each handler re-reads the customer's snapshot instead of applying the payload: deliveries arrive out of order and get replayed, so the event is a hint and the snapshot is the state. Throwing a retryable error answers 503, and the platform delivers again.

import { BillingWebhook } from "@sdxc/billing";
import { isFailure } from "@sdxc/result";

import { billing } from "./billing.js";

async function sync(customerId: string | null): Promise<void> {
	if (customerId === null) return;
	let state = await billing.entitlements.of({ id: customerId });
	if (isFailure(state)) throw state.error;
	await writeProjection(customerId, state.data);
}

export let billingWebhook = new BillingWebhook(
	billing,
	{
		"order.paid": (event) => sync(event.order.customerId),
		"subscription.activated": (event) => sync(event.subscription.customerId),
		"subscription.canceled": (event) => sync(event.subscription.customerId),
		"subscription.revoked": (event) => sync(event.subscription.customerId),
	},
	{ store: deliveries },
);

Deliveries get missed, and an unknown failure may or may not have taken effect, so a periodic sweep re-reads everything. Follow the cursor until it is null, since a short page can still have more behind it:

import { isFailure } from "@sdxc/result";

let cursor: string | undefined;

for (let page = 0; page < 50; page++) {
	let result = await billing.subscriptions.list({ status: ["active"], limit: 100, cursor });
	if (isFailure(result)) break;

	for (let subscription of result.data.items) await sync(subscription.customerId);

	if (result.data.cursor === null) break;
	cursor = result.data.cursor;
}

Pattern: Gating A Route On An Entitlement

The guard reads your own projection, so the platform stays off the request path. Configure the reader once and guard each route:

import billingMiddleware, { readEntitlements, requireEntitlement } from "@sdxc/billing/middleware";
import { isSuccess } from "@sdxc/result";
import { redirect } from "remix/response/redirect";
import { createRouter } from "remix/router";

import { billing } from "./billing.js";

let router = createRouter({
	middleware: [
		billingMiddleware({
			provider: billing,
			entitlements: (context) => readProjection(context.request),
		}),
	],
});

router.get(
	"/reports",
	[requireEntitlement("reports", { onDenied: () => redirect("/pricing", 303) })],
	(context) => Response.json({ products: context.entitlements.products }),
);

router.get("/", async (context) => {
	let read = await readEntitlements(context);
	let pro = isSuccess(read) && read.data?.products.includes("pro") === true;
	return Response.json({ showUpgrade: !pro });
});

The reader runs once per request however many guards and readEntitlements calls share it, and answering null (no signed-in subject) denies every guard.

Pattern: Discount Codes And The Billing Portal

A code typed into your own form becomes a discount id for the checkout, and an existing customer manages their plan on the platform's hosted portal. Both groups are optional, so supports() guards them.

import { supports } from "@sdxc/billing";
import { isFailure } from "@sdxc/result";
import { redirect } from "remix/response/redirect";

import { billing } from "./billing.js";

export async function upgrade(userId: string, code: string | null): Promise<Response> {
	let discount: string | undefined;

	if (code !== null && supports(billing, "discounts")) {
		let found = await billing.discounts.findByCode(code);
		if (isFailure(found)) return new Response("Unknown code", { status: 400 });
		discount = found.data.id;
	}

	let checkout = await billing.checkouts.create({
		product: "team",
		customer: { externalId: userId },
		discount,
		allowDiscountCodes: false,
	});
	if (isFailure(checkout) || checkout.data.url === null) return new Response(null, { status: 502 });
	return redirect(checkout.data.url, 303);
}

export async function manage(userId: string): Promise<Response> {
	if (!supports(billing, "portal")) return new Response(null, { status: 404 });

	let session = await billing.portal.create({
		customer: { externalId: userId },
		returnTo: "https://example.com/settings",
	});
	if (isFailure(session)) return new Response(null, { status: 502 });
	return redirect(session.data.url, 303);
}

Pattern: Metered Usage From A Scheduled Job

A job imports the same module-scope provider, reports consumption, and branches on retryable. An externalId derived from your own row makes a resend count once.

import { supports } from "@sdxc/billing";
import { isFailure } from "@sdxc/result";

import { billing } from "./billing.js";

export async function reportUsage(rows: { teamId: string; hour: string; at: Date }[]) {
	if (!supports(billing, "usage")) return;

	let result = await billing.usage.ingest(
		rows.map((row) => ({
			name: "pings",
			customer: { externalId: row.teamId },
			externalId: `pings_${row.teamId}_${row.hour}`,
			timestamp: row.at,
		})),
	);

	if (isFailure(result) && result.error.retryable) throw result.error;
}

export async function monthToDate(teamId: string, from: Date) {
	if (!supports(billing, "meters")) return null;

	let reading = await billing.meters.quantities({
		meter: "pings",
		customer: { externalId: teamId },
		from,
		to: new Date(),
		interval: "day",
	});
	return isFailure(reading) ? null : reading.data.quantity;
}

Pattern: A Provider Per Tenant

Each tenant bills against its own account: the middleware's factory builds the provider for the request, connection names the account so stored ids stay attributable, and a Secret function reads the tenant's credential on first use.

import billingMiddleware from "@sdxc/billing/middleware";
import { MercadoPagoBilling } from "@sdxc/billing/providers/mercado-pago";
import { createRouter } from "remix/router";

let providers = new Map<string, MercadoPagoBilling>();

function providerFor(tenant: string): MercadoPagoBilling {
	let provider = providers.get(tenant);
	if (provider !== undefined) return provider;

	provider = new MercadoPagoBilling({
		connection: `mercado-pago:${tenant}`,
		accessToken: () => readSecret(`${tenant}/MERCADO_PAGO_ACCESS_TOKEN`),
		webhookSecret: () => readSecret(`${tenant}/MERCADO_PAGO_WEBHOOK_SECRET`),
		notificationURL: `https://${tenant}.example.com/webhooks/billing`,
		products: {
			pro: { kind: "recurring", plan: "2c93808..." },
			book: { kind: "one_time", name: "The book", price: { amount: 10_050, currency: "ars" } },
		},
	});
	providers.set(tenant, provider);
	return provider;
}

let router = createRouter({
	middleware: [billingMiddleware({ provider: (context) => providerFor(tenantOf(context.url)) })],
});

Pattern: Testing A Billing Flow

MemoryBilling keeps real state, so a test buys a product, emits a signed delivery to a real endpoint, and asserts on the outcome. fail() arms an outage on the same instance.

import { BillingWebhook, MemoryWebhookStore } from "@sdxc/billing";
import { MemoryBilling } from "@sdxc/billing/providers/memory";
import { isFailure, unwrap } from "@sdxc/result";
import { RequestContext } from "remix/router";
import { expect, test } from "vitest";

test("a paid order grants its features once", async () => {
	let billing = new MemoryBilling({
		catalog: { pro: { amount: 4900, interval: "month", features: { reports: true } } },
	});

	let customer = await unwrap(
		billing.customers.create({ email: "pat@example.com", externalId: "u_1" }),
	);
	let opened = await unwrap(
		billing.checkouts.create({ product: "pro", customer: { id: customer.id } }),
	);
	let checkout = await unwrap(billing.checkouts.finish(opened.id));
	let order = await unwrap(billing.orders.find(checkout.orderId ?? ""));

	let granted: string[] = [];
	let store = new MemoryWebhookStore();
	let endpoint = new BillingWebhook(
		billing,
		{
			async "order.paid"(event) {
				let state = await unwrap(billing.entitlements.of({ id: event.order.customerId ?? "" }));
				granted.push(...state.products);
			},
		},
		{ store },
	);

	let delivery = await unwrap(billing.webhooks.emit({ type: "order.paid", order }));
	await endpoint.handler(new RequestContext(delivery.request));
	let replay = await unwrap(
		billing.webhooks.emit({ id: delivery.event.id, type: "order.paid", order }),
	);
	await endpoint.handler(new RequestContext(replay.request));

	expect(granted).toEqual(["pro"]);
	expect(store.deliveries.at(0)?.processed).toBe(true);

	billing.fail("entitlements.of", "unknown");
	expect(isFailure(await billing.entitlements.of({ id: customer.id }))).toBe(true);
	billing.heal();

	let portalless = billing.with({ portal: undefined });
	expect(portalless.portal).toBeUndefined();
});

Pattern: Writing A Provider

Implement Billing, report failures as BillingError, type credentials as Secret, and register the core suite plus one suite per optional group the platform has.

import { capabilityConformance, conformance, portalConformance } from "@sdxc/billing/conformance";
import { describe } from "vitest";

import { AcmeBilling } from "./acme.js";

let options = {
	name: "AcmeBilling",
	create: () =>
		new AcmeBilling({
			apiKey: () => readSecret("ACME_TEST_KEY"),
			catalog: { pro: "prod_pro", tokyo: "prod_tokyo" },
		}),
	subscription: { slug: "pro", amount: 4900, currency: "usd" },
	zeroDecimal: { slug: "tokyo", amount: 5000, currency: "jpy" },
};

describe("AcmeBilling against a sandbox account", () => {
	conformance(options);
	portalConformance(options);
	capabilityConformance(options);
});

capabilityConformance fails a provider that declares a group it cannot serve, so declare only what the platform has.

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.