Skip to content

Invoicing & Customers

The invoicing vertical lets an organisation bill its own clients — one-off or recurring — against its Offers & Pricing catalog. The organisation’s data is always the source of truth: an Offer’s price/product mirrors into Stripe (one-way), and an Invoice’s status lives in our own ledger, with Stripe as a swappable execution surface behind a port — not the record of truth. A QontoInvoicingGateway (or any other processor) can implement the same port later without touching the domain.

Customer who the organisation bills
Offer ──< Invoice ──< InvoiceLineItem a one-off bill (snapshotted at issue time)
Offer ──< InvoiceSchedule ──< Invoice a recurring billing agreement
  • Customer — an org-scoped record for the organisation’s own client (name, email, billing address, tax id, an optional currency). It is not a platform account: a Customer has no login and is never a member of the organisation — it’s a plain billing-contact row, closer to how a ticket order snapshots a buyer’s email than to the org’s own user/tenant model.
  • Invoice — our own ledger record. Status is a small explicit machine: draft → open → {paid, void, uncollectible} (the transition table lives in InvoiceService, independent of whatever a processor calls its own invoice states). Line items are snapshotted at creation — description/unitAmountCents are copied from the Offer at that moment, never a live join, so editing or archiving an offer later can’t corrupt a past invoice. An invoice is either created directly (one_off) from an Offer or hand-written line items, or generated by a schedule (recurring).
  • InvoiceSchedule — a recurring billing agreement: a Customer × a recurring Offer × an interval, with a nextInvoiceDate cursor. The generate_invoices job step (see below) claims due schedules and creates+finalizes the period’s invoice.

Money is always stored in minor units (cents), matching every other billing table.

“Billing” names two opposite directions, and only one of them produces an Invoice. Six distinct mechanisms exist today:

MechanismPayer → payeeStripe accountLocal rowsCadenceTrigger
Platform subscriptionOrg → Viiteplatformsubscriptionsrecurrent (Stripe-driven)POST /api/subscriptions/checkout
Admin grantnonesubscriptions (null Stripe ids)never expiresPOST /api/subscriptions/admin/orgs/:orgId/grant-permanent
One-off invoiceCustomer → Orgconnectedinvoices (source: one_off)one-offPOST /api/invoicing + finalize
Recurring invoiceCustomer → Orgconnectedinvoices (source: recurring)month/year, our schedulergenerate_invoices claims due schedules
Ticket orderBuyer → Orgconnectedticket_ordersone-offpublic checkout + Connect webhook
Offer checkoutBuyer → Orgconnectednoneone-off or recurrentpublic checkout — no webhook consumer

Two things to read off this table. The recurring invoice row is the only cadence we drive: a platform subscription recurs because Stripe bills it, whereas a recurring invoice recurs because generate_invoices ticks. And a recurring offer checkout sells a Stripe subscription — Stripe drives that billing, and we mirror each paid invoice into the ledger so it shows up; see Self-serve subscription sales.

Three different parties, easy to conflate because all three involve money and Stripe:

  • Subscriber — an organisation paying Viite for its own access. A subscriptions row on the platform account. Never a Customer; its bills live in Stripe’s portal, surfaced at /settings/billing, deliberately not mirrored locally.
  • Customer — any party the organisation bills, on the org’s connected account. Has no login, and that’s the default case rather than a limitation (see below).
  • Buyer — a ticket or offer purchaser. A buyer who completes a recurring offer checkout is now upserted into customers when their first subscription invoice is paid (see below), so they become a first-class Customer with no new entity. Ticket buyers and one-off offer buyers still have no local identity — folding them in the same way is the remaining direction.

customers and subscriptions are unrelated tables, and nothing links them: activating a subscription never creates, upserts, or backfills a Customer. Someone who bought the All-in plan will not appear under Customers, and that’s deliberate — the two model opposite directions of money:

customerssubscriptions
Whothe org’s own clients, whom the org billssomeone paying the platform for their own access
Created bymanual CRUD (POST /api/customers) — the only writerStripe webhooks / admin grant
Stripe accountthe org’s connected accountthe platform account

The two stripeCustomerId columns therefore aren’t comparable — they name customers on different Stripe accounts. InvoiceService guards this explicitly, refusing to reuse a stamped id unless its stripeAccountId matches the connected account it’s about to invoice on. Bridging the two verticals would mean reconciling that mismatch and deciding which org owns a subscriber’s Customer row; if you want subscribers listed somewhere, a separate view is the cleaner shape.

When an Offer is active and priced (not custom_quote), OfferService pushes it to Stripe as a Product + Price and stamps stripeProductId/stripePriceId back onto the offer — Stripe never writes back into the catalog. Because Stripe Prices are immutable, a price-affecting edit (price, currency, billing model, interval, name, description) archives the old Price and creates a fresh one; archiving the offer archives the Stripe Product too. This sync is silent and automatic — there’s nothing to call explicitly, it happens inside client.offers.create() / client.offers.update() whenever the org has STRIPE_SECRET_KEY configured.

Managing customers and invoices (session-scoped)

Section titled “Managing customers and invoices (session-scoped)”
import { PlatformApiClient } from '@abeauvois/platform-sdk';
const client = new PlatformApiClient({ baseUrl, sessionToken });
const acme = await client.customers.create({
name: 'Acme SARL',
email: 'billing@acme.example',
billingCountry: 'FR',
});
// One-off, from an Offer (snapshots its current name/price):
const invoice = await client.invoices.createOneOff({ customerId: acme.id, offerId: studio.id });
await client.invoices.finalize(invoice.id); // draft -> open, pushes to Stripe, emails the customer
// One-off, hand-written line items (no Offer behind it):
await client.invoices.createOneOff({
customerId: acme.id,
lineItems: [{ description: 'Ad-hoc consulting', quantity: 3, unitAmountCents: 15000 }],
});
// Recurring: a schedule against a `recurring` Offer.
const schedule = await client.invoices.schedules.create({ customerId: acme.id, offerId: monthlyPlan.id });
await client.invoices.schedules.pause(schedule.id);
await client.invoices.schedules.resume(schedule.id);

Endpoints: GET/POST /api/invoicing, GET /api/invoicing/:id, POST /api/invoicing/:id/finalize, POST /api/invoicing/:id/void; schedules are nested under GET/POST /api/invoicing/schedules, POST /api/invoicing/schedules/:id/{pause,resume,cancel}. Reads are org-scoped — any org member can see/manage the org’s own customers and invoices (unlike the owner-scoped Offer catalog, a Customer/Invoice is shared org data, not a personal draft).

GET /api/invoicing also takes sortBy (issueDate/dueDate/totalAmountCents/status) + sortDir and limit/offset for a sorted, paginated ledger, and GET /api/invoicing/summary returns the org-wide accounts-receivable rollup — { paidCents, outstandingCents, overdueCents } (+ counts), computed in one aggregate query so the dashboard’s money tiles stay accurate however the list below is filtered or paged. overdue is the status = 'open' AND dueDate < now subset of outstanding.

Once finalized, an invoice is locked — line items can’t be edited. finalizeInvoice uses Stripe’s real Invoicing API (collection_method: 'send_invoice'): Stripe emails the customer a hosted invoice page and generates a PDF, both linked back onto our Invoice row (stripeHostedInvoiceUrl/stripeInvoicePdfUrl). Without STRIPE_SECRET_KEY configured, finalizing still transitions draft → open locally — invoicing works before a processor is wired up, it just produces no hosted link.

POST /api/invoicing/webhooks/stripe — unauthenticated, raw-body, signature-verified with STRIPE_INVOICING_WEBHOOK_SECRET. Mirrors Stripe’s invoice-lifecycle events back onto our ledger — never the other way around, Stripe never dictates our catalog or invoice numbering, only confirms a transition we already modeled:

Stripe eventEffect
invoice.paidInvoice.status → paid
invoice.voided / invoice.marked_uncollectibleInvoice.status → uncollectible (distinct from an org-initiated void — a processor-side write-off after failed collection)
invoice.payment_failedLogged only (v1) — Stripe’s own Smart Retries handle the retry

Both handlers are idempotent — safe under webhook redelivery.

A self-sourcing job step (same shape as publish/watch — see Author a preset) scans every organisation’s due InvoiceSchedules each tick, claims one via an atomic compare-and-swap on nextInvoiceDate (so a schedule is never double-claimed across concurrent ticks), then creates and finalizes that period’s invoice. A failed generate/finalize is intentionally not retried automatically and the claim is never rolled back — the schedule has already moved to its next period, and the failure is surfaced as a stuck schedule (the dashboard’s /invoice-schedules page flags one whose most recent claimed period has no confirmed open/paid invoice) rather than risking a double-invoice on retry.

Unlike per-organisation automations, this one needs exactly one deployment-wide JobSchedule — and the deployment declares its own. On every boot, reconcileJobSchedules ensures system.generate-invoices exists (daily at 03:00 UTC) and registers it with the cron runner. There is no deploy step to remember and nothing to POST.

The schedule is owned by the system actor (user.id = 'system'), a single seeded, deliberately unauthenticatable user row. It exists because job_schedules.user_id is NOT NULL with an FK to user, while generate_invoices is the one cross-tenant step and belongs to no person. See the 20260717120000_seed_system_user migration for why that beat the alternatives.

The same boot pass re-registers every persisted schedule, not just the system one. job_schedules is our row, but the cron itself lives in pg-boss’s own schedule table — a separate schema, outside drizzle’s migrations. Nothing previously reconciled the two, so restoring the database from a backup that predates a schedule would leave the row present and the cron gone, and every schedule would stop firing with no error. Re-registering on each boot closes that drift.

Verify it converged:

curl -s $API/api/health | jq .systemSchedulesHealthy # true = every system cron is registered

false means recurring invoicing will not fire — the boot reconcile failed, or pg-boss is unavailable. It deliberately does not degrade the health status: billing being down must be visible without pulling the container out of the load balancer.

The pipeline has several independent links, and a break in any of them produces the same symptom: an empty /invoices list. Work down the list in order — each check is a read-only probe.

  1. Is the org a merchant? GET /api/connect/stripe/status must report chargesEnabled: true. finalizeInvoice refuses to issue without a connected account with charges enabled, so an org that never completed Connect onboarding cannot bill anyone at all. This is the most common cause and the easiest to miss, because the failure is at finalize time, not at create time.
  2. Do customers and schedules exist? GET /api/customers and GET /api/invoicing/schedules. A schedule is the only thing generate_invoices reads, and today the only writer of a schedule is a human filling in the /invoice-schedules/new form. No schedule, no invoice — regardless of how many recurring Offers the catalog holds, and regardless of any active subscription.
  3. Did the recurring-billing cron register? GET /api/healthsystemSchedulesHealthy (an AND across every system cron, so false may point at a different one) must be true. The deployment creates and registers system.generate-invoices itself on boot, so false means the reconcile failed or pg-boss is down — check the API logs for [jobs] schedule reconcile.
  4. Are the Offer’s Stripe ids on the right account? GET /api/offers — an offer with a stripePriceId but a null stripeAccountId was mirrored before invoicing moved to direct charges on connected accounts. Its Price doesn’t exist on the org’s account, so checkout rejects it on the account-mismatch guard. Re-saving the offer re-mirrors it.
  5. Is the invoice webhook Connect-enabled? invoice.paid now fires on the connected account. If the endpoint isn’t registered as Connect-enabled in Stripe, invoices stay open forever and never show as paid.
  6. Is it actually there? /invoices lists one-off and recurring invoices together, with a Source column and a source filter distinguishing the two, so a recurring invoice is no longer indistinguishable from a one-off. From a recurring invoice you can open the schedule that minted it; from a schedule (/invoice-schedules/:id) you can see every invoice it has generated; and a customer’s page lists everything billed to them. If a schedule shows no generated invoices, the cause is upstream — walk back up this list.

An active subscription is not a reason to expect invoices

Section titled “An active subscription is not a reason to expect invoices”

If you have an active subscription and no invoices, that is very likely not a bug. A subscription is money flowing to Viite on the platform account; an invoice is money flowing to your org on your connected account. They are unrelated, and no subscription — paid or granted — has ever produced an Invoice row. An admin-granted subscription (stripeCustomerId and stripeSubscriptionId both null) produces no bills in either direction, since it has no Stripe objects behind it at all. Viite’s bills to you live at /settings/billing; your bills to your clients live at /invoices.

Selling a recurring Offer through the public checkout opens a real Stripe subscription-mode session on the org’s connected account: Stripe creates the subscription and charges the buyer’s card every interval, with its own retries and dunning. Because that billing is Stripe-driven, we do not also create an InvoiceSchedule for it — a schedule would make generate_invoices issue a second invoice each period and bill the customer twice. The two recurring mechanisms are mutually exclusive: a schedule is for customers we invoice; a subscription is for buyers Stripe charges.

Instead we mirror. Each subscription invoice fires an invoice.paid event on the connected account — the same place the invoicing webhook already listens (direct charges). When that invoice isn’t one we issued and its billing_reason is a subscription* one, InvoiceService.mirrorSubscriptionInvoice:

  1. upserts the buyer as a Customer (deduped on their connected-account Stripe customer id, stamped with stripeCustomerId/stripeAccountId), and
  2. records a paid, source: 'recurring' Invoice carrying Stripe’s own number, hosted URL and PDF — without issuing anything, so there is no second charge.

It’s idempotent on stripeInvoiceId (a redelivered event returns the existing row) and no-ops for a connected account we don’t recognise. The upshot: a self-serve subscription sale now shows in /invoices as a paid recurring invoice, and its buyer appears under Customers — no manual schedule needed. This reuses the existing invoicing webhook + STRIPE_INVOICING_WEBHOOK_SECRET; that endpoint must be Connect-enabled (it already must be, so invoice.paid for the org’s own issued invoices arrives at all).

One-off offer purchases (mode: 'payment') don’t generate a Stripe invoice, so they aren’t mirrored and their buyer isn’t captured yet — that’s the remaining slice of “every buyer becomes a Customer”.

VarRequiredNotes
STRIPE_SECRET_KEYOptionalShared with subscriptions/ticketing/offers. Without it, Offer→Stripe sync and invoice finalization both no-op locally (no hosted link/PDF).
STRIPE_INVOICING_WEBHOOK_SECRETOptionalSigning secret for /api/invoicing/webhooks/stripe, distinct from the subscriptions/ticketing webhook secrets.