← all parts

Part · billing.subscription

What's actually behind billing.subscription

The part exactly as partkit add billing.subscription vendors it into your repo — verified, locked, every byte readable. Nothing here is mocked.

billing.subscriptionv1.2.0

✓ attested🔒 read-only↑ ctrlai upgradebilling.subscription@1

Lives at parts/billing.subscription/ in your repo — open, owned, readable. Not buried in node_modules. 746 lines of source you can audit.

33 conformance tests passedverified 2026-06-14adapter paddle↗ CI run
content hash 09e6fa8b22…927443pinned in parts.lockctrlai guard fails CI if a single byte changes
tested against node 25.3.0

Public API — what your seam calls

  • billing(db: SqlExecutor): Billing
  • Billing { createCheckout(input: CreateCheckoutInput): Promise<CheckoutSession>; getSubscription(userId: string): Promise<Subscription | null>; cancelAtPeriodEnd(input: CancelInput): Promise<Subscription>; reactivate(input: CancelInput): Promise<Subscription>; changePlan(input: ChangePlanInput): Promise<Subscription>; ingestWebhook(input: IngestWebhookInput): Promise<IngestResult>; onSubscriptionChange(handler: SubscriptionChangeHandler): Unsubscribe; webhookHandler(): (request: Request) => Promise<Response> }
  • billingWebhookHandler(db: SqlExecutor): (request: Request) => Promise<Response>
  • class BillingError extends Error { code: BillingErrorCode }
  • types: SqlExecutor, Billing, Subscription, SubscriptionStatus, CheckoutSession, CreateCheckoutInput, CancelInput, ChangePlanInput, IngestWebhookInput, IngestResult, SubscriptionChangeHandler, SubscriptionChangeEvent, Unsubscribe, PlanCatalog, Plan, BillingErrorCode

Invariants — guarantees the contract pins

  1. Importing the part performs no I/O and never throws; the Stripe client and DB access are built lazily on first use, and every failure (invalid input, a Stripe API error, or a storage error) surfaces as a typed BillingError whose message has every secret env value redacted, so a raw error carrying credentials never escapes.
  2. Invalid input — a blank or unknown planId, an empty userId, or an unknown subscriptionId — fails fast with a typed BillingError (invalid_input or not_found) and issues zero SQL writes.
  3. Inbound webhook handling is idempotent under at-least-once delivery: a UNIQUE constraint on billing_events.stripe_event_id means a redelivered event (same evt_ id) is recorded and applied at most once; reprocessing produces no second state change and no duplicate row.
  4. Subscription state in billing_subscriptions derives solely from verified webhook events, never from client input or the success redirect: createCheckout writes no subscription row; only ingestWebhook, after signature verification, upserts billing_subscriptions.
  5. Every inbound webhook is verified with a timing-safe HMAC-SHA256 over the raw request bytes (signed content '<t>.<rawbody>', key = BILLING_WEBHOOK_SECRET) before any state change; a tampered body, wrong secret, or absent/garbled signature header is rejected with BillingError(invalid_signature) and writes nothing, and a signed timestamp outside the +/-300s window is rejected with BillingError(timestamp_out_of_window).
  6. No card data is ever stored or logged: billing_subscriptions and billing_events persist only Stripe identifiers, price id, status, period end, and event type/received_at — never card numbers, CVCs, or raw webhook payloads.
  7. The part owns only the billing_ tables and never the principal: user identity is an opaque user_id with no foreign key to any auth.session table; every statement is fully parameterized so SQL metacharacters in ids are stored literally; every statement targets only billing_subscriptions or billing_events.
  8. Entitlement is exactly status in {active, trialing}: getSubscription returns the mirrored row with a derived entitled flag true only for those two statuses; canceled, unpaid, past_due, incomplete, incomplete_expired, and paused are not entitled.

Owns in your Postgres

billing_subscriptionsbilling_events

Dependencies

zero-dep — runs on your Postgres

Swappable adapters

stripepaddle
SOURCEparts/billing.subscription/21 files · click to read
parts/billing.subscription/src/index.tstypescript · 1,546 bytes
/**
 * billing.subscription — public interface. The ONLY legal import surface.
 * Contract: ../contract.json · What your app must provide: ../seams.md
 *
 * Subscription billing over a vendor-neutral contract: hosted checkout, a
 * webhook-derived subscription mirror, cancel/reactivate/change-plan, and a
 * derived `entitled` flag. The selected payment adapter (Stripe in v1) is the
 * vendor seam; state lives in the app's Postgres via the SqlExecutor seam.
 * Importing this module performs no I/O.
 */
import { adapter } from "../adapters/selected/adapter";
import { makeBilling, makeWebhookHandler } from "./internal/billing";
import type { Billing, SqlExecutor } from "./internal/types";

/** Bind the billing operations to the app's database seam + the selected adapter. */
export function billing(db: SqlExecutor): Billing {
  return makeBilling(db, adapter);
}

/** The handler the app mounts at POST /api/webhooks/billing (raw body required). */
export function billingWebhookHandler(db: SqlExecutor): (request: Request) => Promise<Response> {
  return makeWebhookHandler(db, adapter);
}

export { BillingError } from "./internal/errors";
export type { BillingErrorCode } from "./internal/errors";
export type {
  Billing,
  CancelInput,
  ChangePlanInput,
  CheckoutSession,
  CreateCheckoutInput,
  IngestResult,
  IngestWebhookInput,
  Plan,
  PlanCatalog,
  ProrationBehavior,
  Subscription,
  SubscriptionChangeEvent,
  SubscriptionChangeHandler,
  SubscriptionStatus,
  SqlExecutor,
  Unsubscribe,
} from "./internal/types";