Billing Contracts
Scope: src/billing/ — the BillingAdapter interface, the Stripe / Lemon
Squeezy / Paddle / Polar adapters, the createBilling facade, the credits
ledger, the webhook handling, and the server router.
Guarantees
- Unified interface. All four providers implement the same
BillingAdapterand return normalized models (BillingCustomer,BillingSubscriptionwith a canonicalSubscriptionStatus,CheckoutSession,BillingEvent). - Webhook signature verification. When a
webhookSecretis configured,handleWebhookrejects any payload whose signature does not verify, using the provider's scheme (Stripet.payloadHMAC, Lemon Squeezy body HMAC, Paddlets:payloadHMAC, Polar Standard-Webhooks), with constant-time comparison and timestamp-skew rejection where the scheme includes a timestamp. - Idempotent webhook processing. Deliveries are de-duplicated by event id via
the KV (24h window); a retried delivery is parsed and returned but its side
effects (credit grant,
onEvent) run at most once. - At-least-once credit granting tied to payment. On
payment.succeeded, the matched plan'screditsare granted to the resolved account exactly once per event id (subject to the dedupe window). Granting requires a configured KV. - Credits-ledger atomicity.
grant/consumemutate the balance with the KV's atomicincrBy, so concurrent operations across instances stay consistent.consumerefuses to overdraw unlessallowNegativeis set. - 404 → null.
getCustomer/getSubscriptionreturnnullfor not-found rather than throwing; other non-OK responses throwBillingErrorwith status.
Non-guarantees
- No provider-API drift protection. Adapters target the providers' current REST APIs; a breaking provider change requires an adapter update.
- Capability gaps are explicit, not emulated. Where a provider lacks a
portable capability (e.g. Paddle
reportUsage/ hosted portal), the adapter throws a clearBillingErroror omits the optional method rather than faking it. - Dedupe/credit-grant require a global KV. Without a KV, webhooks are still verified and parsed, but dedupe and auto-grant are skipped.
- No reconciliation/polling. State is event-driven; if a webhook is never
delivered, Covara does not poll the provider to reconcile (handle critical
flows in
onEventand consider a periodic reconcile task). - Account resolution is best-effort. Auto-grant resolves the account from
metadata.accountId/userId(set this at checkout) or the customer id; if neither is present, no credits are granted.