Billing

Mock to money is a config change.

One BillingDriver contract, three implementations. The driver is picked by which env keys exist — code never changes: Lemon Squeezy → Stripe → mock. LS first because a merchant of record handles payouts and taxes where the operator can't open Stripe.

Selection

driver resolution
import { billingEnv, getBillingDriver } from "@tollgate/sdk";

const config = billingEnv(env);          // lifts the env vars below
const driver = getBillingDriver(config); // ls | stripe | mock

const { url } = await driver.createCheckout({
  planId: "credits_1k",                  // $9 → 1,000 credits
  accountId, email,
  successUrl: site + "/dashboard?purchased=1",
  cancelUrl: site + "/dashboard",
});

Environment variables

variableproviderwhat it is
LEMONSQUEEZY_API_KEYLSAPI key — its presence turns LS on
LEMONSQUEEZY_STORE_IDLSstore id
LEMONSQUEEZY_VARIANT_CREDITS_1KLSvariant id of the $9 block product
LEMONSQUEEZY_WEBHOOK_SECRETLSsigning secret of your webhook
STRIPE_SECRET_KEYStripesecret key — presence turns Stripe on
STRIPE_PRICE_CREDITS_1KStripeprice id of the $9 block
STRIPE_WEBHOOK_SECRETStripeendpoint signing secret
SITE_URLallbase URL for redirect/checkout links

Secrets go in wrangler secret put / .dev.vars, never in code or config files you commit.

Webhooks

Both providers post to your server; verify before fulfilling. Verification helpers do constant-time HMAC checks (Stripe also gets a 5-minute replay window), and fulfillment is idempotent on the provider's reference — retries can't double-credit.

lemon squeezy endpoint
const event = await verifyLemonSqueezyWebhook(config, rawBody, req.headers.get("x-signature"));
const billing = event && lsEventToBillingEvent(event);
if (billing) await tollgate.store.addCredits(
  billing.accountId, billing.credits, billing.externalRef,
  billing.amountUsdCents, billing.provider);

verifyStripeWebhook / stripeEventToBillingEvent mirror this for checkout.session.completed.

Provider setup, concretely

The mock driver

No keys configured → checkout URLs point at a local /billing/mock-checkout page whose "pay" button calls a completion endpoint. That endpoint is hard-gated on the mock driver being active, so it cannot mint credits once real billing exists. This is how the whole flow stays testable in CI and on a laptop with zero accounts.

Refunds & disputes

Not automated in MVP — by policy, not by accident: the purchase ledger keeps the provider reference, so a refund is "subtract the block, note the ref" in one SQL statement. Automate it when volume justifies it.