Skip to content

Billing types

Billing operates at the business level. Each business row carries billingMode (how they pay) and billingStatus (current standing). These are PostgreSQL enums defined in apps/scheduler-api/src/db/schema.ts.

Mode Description Default
none No platform billing configured yet
a_la_carte Per-usage charges — billed as services are consumed
contract Fixed-rate contract (monthly or annual)

The mode is set at provisioning time and can be changed by an admin.

When billingMode = 'contract', a collectionMethod field controls how Stripe collects:

Method How payment is collected
subscription Recurring Stripe subscription (auto-charged)
prepaid Single upfront charge for the contract period
external Invoiced outside Stripe (e.g. wire transfer, net-terms)

Contracts have a contractEndsAt timestamp. When a contract term ends, the contractExpiryAction determines what happens:

Action Effect
restrict Access curtailed — business moves to restricted billing status
auto_convert Automatically flips billingMode to a_la_carte
comp_continue Access continues at no charge (complimentary extension)
Status Meaning
active In good standing; all features available
past_due Payment overdue; within the grace period (pastDueGraceDays, default 7 days)
restricted Access curtailed — grace period expired or contract restriction triggered

Default status is active.

stateDiagram-v2
    [*] --> active : provisioned

    active --> past_due : Stripe payment fails
    past_due --> active : payment retried successfully
    past_due --> restricted : grace period expires

    active --> restricted : contract ends with restrict action
    restricted --> active : admin re-activates / payment resolved

    active --> active : contract ends with auto_convert\n(billingMode flips to a_la_carte)
    active --> active : contract ends with comp_continue\n(access unchanged)
  1. Stripe webhooks — payment events (invoice.paid, invoice.payment_failed) trigger immediate status updates.
  2. Billing scanner — a cron job finds overdue businesses, attempts retry charges via Stripe, and transitions status on success or grace-period expiry.
  3. Both paths mirror results into billing_invoice_mirror and append to business_billing_audit.

Live, money-moving actions (domain purchases) are gated by a runtime toggle in the platform_config table — not an env var:

Config key Default Effect when false
domains_purchase_enabled false Domain purchases fail with a clear error; no Porkbun call is made

The money gate is toggled by an admin in the portal. It takes effect immediately without a deploy.

Businesses on a_la_carte or contract (subscription/prepaid collection) have:

  • Stripe Customer — linked via business.stripeCustomerId
  • Stripe Subscription (subscription collection) — tracked in business.stripeSubscriptionId
  • Invoice mirror — local cache in billing_invoice_mirror table, upserted by webhook on every invoice event
  • Audit log — every billing event written to business_billing_audit

Businesses with billingMode = 'none' or collectionMethod = 'external' have a Stripe Customer but no active Stripe Subscription.

Stripe products and prices are synced from code on boot via modules/billing/stripe-catalog-sync.ts. The catalog is defined in code; Stripe is kept in sync, not the other way around.