billing

Products, prices, subscriptions, invoices, and payments with optional Stripe integration.

Billing

Products, tiered prices, subscriptions, invoices, and payments. All monetary amounts are stored in the smallest currency unit (e.g. cents for USD). Each table includes an optional Stripe ID column for syncing with Stripe, but Stripe is not required.

Tables

products

ColumnTypeDescription
iduuidPrimary key, auto-generated.
org_iduuidReferences organizations. Cascade delete.
nametextProduct name.
descriptiontextOptional product description.
is_activebooleanWhether the product is available for new subscriptions. Defaults to true.
stripe_product_idtextStripe product ID. NULL if not using Stripe. Unique per org when set.
created_attimestamptzRow creation timestamp.
updated_attimestamptzAuto-updated via trigger.
metadatajsonbArbitrary key-value data. Defaults to empty object.

prices

ColumnTypeDescription
iduuidPrimary key, auto-generated.
org_iduuidReferences organizations. Cascade delete.
product_iduuidReferences products. Cascade delete.
nametextOptional price tier name (e.g. "Starter", "Pro").
amountnumericPrice in smallest currency unit (e.g. cents).
currencytextISO currency code. Defaults to USD.
intervalbilling_intervalBilling frequency. Defaults to month.
interval_countintegerNumber of intervals per billing cycle (e.g. 3 for quarterly). Defaults to 1.
trial_daysintegerFree trial length in days. Defaults to 0.
is_activebooleanWhether this price is available for new subscriptions. Defaults to true.
stripe_price_idtextStripe price ID. NULL if not using Stripe. Unique per org when set.
created_attimestamptzRow creation timestamp.
updated_attimestamptzAuto-updated via trigger.
metadatajsonbArbitrary key-value data. Defaults to empty object.

subscriptions

ColumnTypeDescription
iduuidPrimary key, auto-generated.
org_iduuidReferences organizations. Cascade delete.
contact_iduuidReferences contacts. Set NULL on contact delete.
company_iduuidReferences companies. Set NULL on company delete.
price_iduuidReferences prices. Cascade delete.
statussubscription_statusCurrent subscription state. Defaults to active.
quantityintegerNumber of seats or licenses. Defaults to 1.
current_period_starttimestamptzStart of the current billing period.
current_period_endtimestamptzEnd of the current billing period.
cancel_attimestamptzScheduled cancellation date.
canceled_attimestamptzActual cancellation timestamp.
trial_starttimestamptzTrial period start.
trial_endtimestamptzTrial period end.
stripe_subscription_idtextStripe subscription ID. Unique per org when set.
created_attimestamptzRow creation timestamp.
updated_attimestamptzAuto-updated via trigger.
metadatajsonbArbitrary key-value data. Defaults to empty object.

invoices

ColumnTypeDescription
iduuidPrimary key, auto-generated.
org_iduuidReferences organizations. Cascade delete.
subscription_iduuidReferences subscriptions. Set NULL on subscription delete.
contact_iduuidReferences contacts. Set NULL on contact delete.
company_iduuidReferences companies. Set NULL on company delete.
numbertextHuman-readable invoice number (e.g. INV-2026-001).
statusinvoice_statusInvoice state. Defaults to draft.
currencytextISO currency code. Defaults to USD.
subtotalnumericAmount before tax, in smallest currency unit. Defaults to 0.
taxnumericTax amount, in smallest currency unit. Defaults to 0.
totalnumericTotal amount, in smallest currency unit. Defaults to 0.
amount_paidnumericAmount already paid. Defaults to 0.
amount_duenumericRemaining balance. Defaults to 0.
issued_attimestamptzDate the invoice was sent.
due_attimestamptzPayment due date.
paid_attimestamptzDate payment was received.
stripe_invoice_idtextStripe invoice ID. Unique per org when set.
created_attimestamptzRow creation timestamp.
updated_attimestamptzAuto-updated via trigger.
metadatajsonbArbitrary key-value data. Defaults to empty object.

payments

ColumnTypeDescription
iduuidPrimary key, auto-generated.
org_iduuidReferences organizations. Cascade delete.
invoice_iduuidReferences invoices. Set NULL on invoice delete.
amountnumericPayment amount in smallest currency unit.
currencytextISO currency code. Defaults to USD.
statuspayment_statusPayment state. Defaults to pending.
methodtextPayment method: card, bank_transfer, check, etc.
referencetextExternal payment reference or transaction ID.
paid_attimestamptzTimestamp when payment was received.
stripe_payment_intent_idtextStripe PaymentIntent ID. Unique per org when set.
created_attimestamptzRow creation timestamp.
updated_attimestamptzAuto-updated via trigger.
metadatajsonbArbitrary key-value data. Defaults to empty object.

Enums

billing_interval

ValueDescription
monthMonthly billing cycle.
yearAnnual billing cycle.
one_timeSingle charge, no recurrence.

subscription_status

ValueDescription
trialingSubscription is in a free trial period.
activeSubscription is active and billing normally.
past_duePayment failed but subscription has not been canceled yet.
canceledSubscription has been canceled.
unpaidSubscription is unpaid and suspended.

invoice_status

ValueDescription
draftInvoice is being prepared and not yet sent.
openInvoice has been sent and is awaiting payment.
paidInvoice has been paid in full.
voidInvoice has been voided and is no longer valid.
uncollectibleInvoice has been written off as uncollectible.

payment_status

ValueDescription
pendingPayment has been initiated but not yet confirmed.
succeededPayment completed successfully.
failedPayment attempt failed.
refundedPayment has been refunded.

Row-Level Security

All five tables are scoped to the current user's organization via get_user_org_id(). Any org member can select, insert, and update rows. Only admins (checked via is_admin()) can delete products, prices, subscriptions, invoices, or payments.

Dependencies

  • crm -- contacts, companies
  • identity (transitive via crm) -- organizations, users, get_user_org_id(), is_admin(), set_updated_at()

Example Queries

Calculate monthly recurring revenue (MRR) across all active subscriptions:

SELECT
  sum(
    CASE p.interval
      WHEN 'month' THEN p.amount * s.quantity
      WHEN 'year'  THEN (p.amount * s.quantity) / 12
      ELSE 0
    END
  ) / 100.0 AS mrr_dollars
FROM subscriptions s
JOIN prices p ON p.id = s.price_id
WHERE s.status = 'active';

Break down annual recurring revenue (ARR) by product:

SELECT
  pr.name AS product,
  count(s.id) AS active_subscriptions,
  sum(s.quantity) AS total_seats,
  sum(
    CASE p.interval
      WHEN 'month' THEN p.amount * s.quantity * 12
      WHEN 'year'  THEN p.amount * s.quantity
      ELSE p.amount * s.quantity
    END
  ) / 100.0 AS arr_dollars
FROM products pr
JOIN prices p ON p.product_id = pr.id
JOIN subscriptions s ON s.price_id = p.id AND s.status = 'active'
GROUP BY pr.id, pr.name
ORDER BY arr_dollars DESC;

List outstanding invoices with urgency classification:

SELECT
  i.number,
  co.name AS company,
  i.total / 100.0 AS total_dollars,
  i.amount_due / 100.0 AS due_dollars,
  i.due_at,
  CASE
    WHEN i.due_at < now() THEN 'overdue'
    WHEN i.due_at < now() + interval '7 days' THEN 'due_soon'
    ELSE 'upcoming'
  END AS urgency
FROM invoices i
LEFT JOIN companies co ON co.id = i.company_id
WHERE i.status IN ('open', 'past_due')
ORDER BY i.due_at ASC;

Show payment history for a specific invoice:

SELECT
  p.id,
  p.amount / 100.0 AS amount_dollars,
  p.currency,
  p.status,
  p.method,
  p.reference,
  p.paid_at
FROM payments p
WHERE p.invoice_id = '<invoice_id>'
ORDER BY p.created_at ASC;

List subscriptions expiring within the next 30 days:

SELECT
  s.id,
  co.name AS company,
  pr.name AS product,
  s.quantity AS seats,
  s.status,
  s.current_period_end
FROM subscriptions s
JOIN prices p ON p.id = s.price_id
JOIN products pr ON pr.id = p.product_id
LEFT JOIN companies co ON co.id = s.company_id
WHERE s.status = 'active'
  AND s.current_period_end < now() + interval '30 days'
ORDER BY s.current_period_end ASC;