[ Operations ]
@sdxc/billing
Vendor-neutral billing providers and a webhook endpoint that verifies deliveries
- Installs with
- @sdxc/api-client@sdxc/crypto@sdxc/dates@sdxc/logger@sdxc/result@sdxc/uuid@sdxc/webhooks
- Used by
- uptime, auth-saas, reader, books
- Source
- packages/billing
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
| Entry | Exports |
|---|---|
@sdxc/billing | Billing, the models, BillingError, supports(), BillingWebhook, helpers |
@sdxc/billing/middleware | billing() (default), requireEntitlement(), readEntitlements() |
@sdxc/billing/middleware/cli | billing() (default) for an @sdxc/cli command line |
@sdxc/billing/providers/polar | PolarBilling |
@sdxc/billing/providers/stripe | StripeBilling |
@sdxc/billing/providers/mercado-pago | MercadoPagoBilling |
@sdxc/billing/providers/memory | MemoryBilling, an in-memory platform for tests |
@sdxc/billing/conformance | The 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.createrequiresexternalId, your own subject id and the join key; a taken one answersconflict.updatewithexternalIdadopts a platform customer that has none.The catalog and discounts are read-only: products, prices and coupons are created in the platform's dashboard.
findByCodeturns a typed code into the idcheckouts.createtakes.CreateCheckoutInputtakesproductplus optionalcustomer,email,returnTo,discount,quantity,metadata,allowDiscountCodesandidempotencyKey. Withoutcustomerthe hosted page collects the buyer.idempotencyKeyreturns the same session on platforms with idempotency and is recorded on the session elsewhere.Subscriptions are created by a completed checkout.
cancelends one now, or at period end withatPeriodEnd: true, which a platform that can only cancel immediately answersunsupported.entitlements.ofanswers everything a customer holds now, the snapshot to write into your own tables.usage.ingestanswers{ accepted }, counts a repeatedexternalIdonce and chunks to the platform's limit itself. A meter read names its whole window:from,toandinterval.A list skips a row naming a product the provider was not configured with; only
cursor === nullends 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, aSecretwebhookSecret?: the endpoint's signing secret as Polar issued it, aSecretproducts?,meters?,features?: Polar product, meter and benefit ids by your own slugsconnection?: 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: aSecretcatalog:{ product, price }Stripe ids by your own slugs (StripeCatalogEntry)webhookSecret?: aSecretmeters?: Stripe meter ids by your own slugsportalConfiguration?: the portal configuration sessions open againstexternalIdKey?: the customer metadata key holding your id, defaults to"external_id"connection?: defaults to"stripe";baseURL?: aURL, 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: aSecretproducts?: by your own slugs,{ kind: "recurring", plan }naming a stored plan, or{ kind: "one_time", name, price }priced here; both takefeatures?webhookSecret?: aSecretnotificationURL?: where the platform posts deliveries for checkouts this instance opensbackURLs?:{ 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?:MemoryProductSeedby slug:amount, plus optionalcurrency("usd"),name,description,interval(recurring),meter(metered),features,credits(meter balances granted) andarchiveddiscounts?:MemoryDiscountSeed[], each with optionalid,code,name,percentageoramountandcurrency,products,maxRedemptions,redemptions,startsAt,endsAtfaults?:MemoryFaults, failures armed from the startwebhookSecret?: the base64 secret emitted deliveries are signed withconnection?: 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", aMemoryFaultTarget); a method-level fault wins.heal(target?): disarms one target, or all.with(overrides): aBillingwith the named groups replaced (MemoryBillingOverrides); an optional group set toundefinedis absent.webhooks.emit(event): signs aMemoryEmitEvent(aBillingEventPayloadplus optionalid) and answers aMemoryDelivery{ request, body, headers, event }; reusing anidmodels 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 suitescreate(): builds the provider under test, once per testsubscription: a recurring catalog product, as aConformanceProduct({ slug, amount, currency, priceId? })zeroDecimal: a product priced in a currency with no minor units, such as JPYmeter?: required when the provider declaresmetersmissing?: well-formed ids holding no record (ConformanceMissingIds), a fresh UUID by defaultemail?(): builds an unused address, unique per call by default
Types
Models:
Customer,Product,Price,Checkout,Subscription,Order,Discount,PortalSession,UsageRecord,MeterQuantity,UsageIngest. Each carriesproviderData, the platform's own payload, worth storing beside your columns.EntitlementState: the customer's ids,productsandfeaturesby your own slugs,meters,subscriptions,readAtandproviderData, withMeterBalance{ meter, credited, consumed, balance }andEntitlementSubscriptioncarrying the current period.Moneyis{ amount, currency }in integer minor units;Costis the same withamountas a decimal string, for per-unit usage costs too small for a number.Currencyis the ISO code.CustomerRefis{ id }or{ externalId }.Page<T>is{ items, cursor }.UsageEventis{ name, customer, externalId?, timestamp?, metadata?, cost? }.BillingEventis{ id, raw }plus aBillingEventPayload:customer.created,customer.updated,checkout.completed,subscription.activated,subscription.updated,subscription.canceled,subscription.revoked,order.paid,order.refunded, orunrecognizedwith the platform'sproviderType.BillingEventTypeandBillingEventOf<T>name them;BillingWebhookHandler<T>,BillingWebhookHandlersandBillingWebhookOptionstype the endpoint.Secretisstring | (() => 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 }; anEntitlementStatesatisfies 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*Apiinterface.
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.