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.
The model
Section titled “The model”Customer who the organisation billsOffer ──< 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 amemberof 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 inInvoiceService, independent of whatever a processor calls its own invoice states). Line items are snapshotted at creation —description/unitAmountCentsare copied from theOfferat 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 anOfferor hand-written line items, or generated by a schedule (recurring). - InvoiceSchedule — a recurring billing agreement: a
Customer× arecurringOffer× an interval, with anextInvoiceDatecursor. Thegenerate_invoicesjob 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.
Every way money moves
Section titled “Every way money moves”“Billing” names two opposite directions, and only one of them produces an Invoice. Six distinct
mechanisms exist today:
| Mechanism | Payer → payee | Stripe account | Local rows | Cadence | Trigger |
|---|---|---|---|---|---|
| Platform subscription | Org → Viite | platform | subscriptions | recurrent (Stripe-driven) | POST /api/subscriptions/checkout |
| Admin grant | — | none | subscriptions (null Stripe ids) | never expires | POST /api/subscriptions/admin/orgs/:orgId/grant-permanent |
| One-off invoice | Customer → Org | connected | invoices (source: one_off) | one-off | POST /api/invoicing + finalize |
| Recurring invoice | Customer → Org | connected | invoices (source: recurring) | month/year, our scheduler | generate_invoices claims due schedules |
| Ticket order | Buyer → Org | connected | ticket_orders | one-off | public checkout + Connect webhook |
| Offer checkout | Buyer → Org | connected | none | one-off or recurrent | public 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.
Customer, subscriber, buyer
Section titled “Customer, subscriber, buyer”Three different parties, easy to conflate because all three involve money and Stripe:
- Subscriber — an organisation paying Viite for its own access. A
subscriptionsrow 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
customerswhen 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.
A Customer is not a subscriber
Section titled “A Customer is not a subscriber”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:
customers | subscriptions | |
|---|---|---|
| Who | the org’s own clients, whom the org bills | someone paying the platform for their own access |
| Created by | manual CRUD (POST /api/customers) — the only writer | Stripe webhooks / admin grant |
| Stripe account | the org’s connected account | the 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.
Offer → Stripe: a one-way mirror
Section titled “Offer → Stripe: a one-way mirror”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.
Reconciliation (webhook)
Section titled “Reconciliation (webhook)”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 event | Effect |
|---|---|
invoice.paid | Invoice.status → paid |
invoice.voided / invoice.marked_uncollectible | Invoice.status → uncollectible (distinct from an org-initiated void — a processor-side write-off after failed collection) |
invoice.payment_failed | Logged only (v1) — Stripe’s own Smart Retries handle the retry |
Both handlers are idempotent — safe under webhook redelivery.
Recurring generation (generate_invoices)
Section titled “Recurring generation (generate_invoices)”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 registeredfalse 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.
Why you see no recurrent invoices
Section titled “Why you see no recurrent invoices”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.
- Is the org a merchant?
GET /api/connect/stripe/statusmust reportchargesEnabled: true.finalizeInvoicerefuses 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. - Do customers and schedules exist?
GET /api/customersandGET /api/invoicing/schedules. A schedule is the only thinggenerate_invoicesreads, and today the only writer of a schedule is a human filling in the/invoice-schedules/newform. No schedule, no invoice — regardless of how many recurring Offers the catalog holds, and regardless of any active subscription. - Did the recurring-billing cron register?
GET /api/health→systemSchedulesHealthy(an AND across every system cron, sofalsemay point at a different one) must betrue. The deployment creates and registerssystem.generate-invoicesitself on boot, sofalsemeans the reconcile failed or pg-boss is down — check the API logs for[jobs] schedule reconcile. - Are the Offer’s Stripe ids on the right account?
GET /api/offers— an offer with astripePriceIdbut a nullstripeAccountIdwas 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. - Is the invoice webhook Connect-enabled?
invoice.paidnow fires on the connected account. If the endpoint isn’t registered as Connect-enabled in Stripe, invoices stayopenforever and never show as paid. - Is it actually there?
/invoiceslists 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.
Self-serve subscription sales
Section titled “Self-serve subscription sales”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:
- upserts the buyer as a
Customer(deduped on their connected-account Stripe customer id, stamped withstripeCustomerId/stripeAccountId), and - records a
paid,source: 'recurring'Invoicecarrying 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”.
Environment
Section titled “Environment”| Var | Required | Notes |
|---|---|---|
STRIPE_SECRET_KEY | Optional | Shared with subscriptions/ticketing/offers. Without it, Offer→Stripe sync and invoice finalization both no-op locally (no hosted link/PDF). |
STRIPE_INVOICING_WEBHOOK_SECRET | Optional | Signing secret for /api/invoicing/webhooks/stripe, distinct from the subscriptions/ticketing webhook secrets. |
