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.
Line amounts are entered excluding VAT. An invoice carries one VAT rate for the whole
document, vatRateBps in basis points (2000 = 20 %, 1000 = 10 %, 550 = 5.5 %, 0 = no
VAT — the four the dashboard offers, matching the French standard, intermediate and reduced rates;
the API accepts any whole number of basis points up to 100 %). From the lines and the rate the
ledger derives the three amounts a French invoice must state: subtotalAmountCents (the base
excluding VAT), vatAmountCents, and totalAmountCents (what the customer pays). VAT is rounded
per line, then summed — the rounding Stripe applies when a tax rate is attached to each invoice
item, so the ledger and the Stripe-rendered invoice agree to the cent. On finalize, the rate becomes
an exclusive TaxRate on the organisation’s connected account (created once per rate, found by
percentage after that) and is attached to every invoice item. A recurring InvoiceSchedule carries
its own vatRateBps, stamped on every invoice it generates. An omitted rate is 0, which is also
what every invoice issued before VAT existed carries.
Every way money moves
Section titled “Every way money moves”“Billing” names two opposite directions, and only one of them produces an Invoice the organisation
issues. Every mechanism runs as a direct charge on a connected account — the platform is not a
merchant of record and takes no fee:
| Mechanism | Payer → payee | Stripe account | Local rows | Cadence | Trigger |
|---|---|---|---|---|---|
| Platform plan (Pro) | Org → Viite | viite’s connected account | entitlements | recurrent (Stripe-driven) | POST /api/entitlements/checkout → customer.subscription.* on the offers webhook |
| Credit top-up | User → Viite | viite’s connected account | credit_transactions | one-off | POST /api/credits/checkout → checkout.session.completed on the offers webhook |
| Admin grant | — | none | entitlements (null Stripe ids) | never expires | POST /api/entitlements/admin/orgs/:orgId/grant-permanent |
| One-off invoice | Customer → Org | the org’s connected account | invoices (source: one_off) | one-off | POST /api/invoicing + finalize |
| Recurring invoice | Customer → Org | the org’s connected account | invoices (source: recurring) | month/year, our scheduler | generate_invoices claims due schedules |
| Offer checkout | Buyer → Org | the org’s connected account | customers + invoices (recorded, never issued) | one-off or recurrent | public checkout → /api/offers/webhooks/stripe records a one-off sale; a subscription’s invoices are mirrored by /api/invoicing/webhooks/stripe |
Ticket orders no longer move money: paid ticketing sold through a destination charge on the
platform account, the merchant-of-record role that was retired, so a priced order now answers 409
and paid tickets return as an Offer on the organiser’s account (see
Ticketing & Events).
Two things to read off this table. The recurring invoice row is the only cadence we drive: a
platform plan 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. An
entitlementsrow, granted by a subscription sold as an Offer on viite’s own connected account — the platform is not a merchant of record, so there is no platform-account subscription, no in-app checkout and no billing portal. Never a Customer; the org compares plans and starts that purchase at/upgrade, and the Stripe objects behind it are 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. Offer buyers are upserted into
customerseither way (see below): a recurring checkout when its first subscription invoice is paid, a one-off one when the Checkout session itself is paid — so they become first-class Customers with no new entity. Ticket buyers have no local identity, and no purchase either: ticketing is unpriced today (a priced order 409s and releases its inventory), so there is nothing to record until paid tickets return as an Offer on the organiser’s account.
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 '@viite-ai/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, PATCH /api/invoicing/:id (a draft
only — any of customerId, offerId / lineItems (either replaces the lines), vatRateBps,
dueDate; client.invoices.updateDraft(id, …)), POST /api/invoicing/:id/finalize,
POST /api/invoicing/:id/void, POST /api/invoicing/:id/reset; schedules are nested under GET/POST /api/invoicing/schedules,
POST /api/invoicing/schedules/:id/{pause,resume,cancel}.
Everything here is org-scoped, and the role splits reads from writes. Any org member READS the
org’s own invoices and schedules (unlike the owner-scoped Offer catalog, an Invoice is shared org
data, not a personal draft). Every WRITE is owner/admin — create, edit, finalize, void and reset
an invoice, and create, pause, resume or cancel a schedule, all gated on requireOrgRole(c, 'admin')
and answering 403 otherwise. Issuing an invoice bills in the organization’s name, on its number
sequence and its Stripe account; that is not a personal draft either. The dashboard mirrors it — a
member sees every invoice and none of the actions. Customer writes are not gated this way: a
member may still add and edit the org’s customers.
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.
A draft stays editable until it is finalized — the dashboard’s /invoices/new saves a draft, and
the draft’s page is the same form with Save draft and Finalize as two distinct actions.
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.
Recovery: resetting a wrongly-issued invoice
Section titled “Recovery: resetting a wrongly-issued invoice”POST /api/invoicing/:id/reset (client.invoices.reset(id)) detaches the Stripe invoice and returns
the row to draft so it can be corrected and issued again under its own, unchanged number. It is
owner/admin like every other write here, and the only operation that walks back a terminal status.
The licence to do that is read from Stripe, never from our own status: the issued invoice must have
collected nothing and be worth nothing there (amountPaidCents === 0 && totalCents === 0). That is
precisely what a failed issue leaves behind — an invoice Stripe finalized at zero and therefore marked
paid on the spot, whose invoice.paid our webhook mirrored as a settled invoice nobody paid. Any
Stripe invoice carrying value, paid or not, is a real document and is refused: void it, or issue a
credit note.
The row’s stripe_* columns are cleared rather than kept — a stale id would let the dead invoice’s
next invoice.paid land back on the row, and markPaid is idempotent by status, not by amount. The
old zero invoice stays on the connected account as a papertrail, because a paid Stripe invoice can be
neither voided nor credited (credit_notes refuses: “must not exceed the remaining creditable amount
(€0.00)”).
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
system workflow) 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 viite’s own connected 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 in your ledger. An admin-granted entitlement (stripeCustomerId and
stripeSubscriptionId both null) produces no bills in either direction, since it has no Stripe
objects behind it at all. What Viite charges you is bought on viite.ai and receipted by Stripe from
there — the only in-app page about it is the plan comparison at /upgrade; 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') are captured the same way, but from the offers webhook
rather than the invoicing one: a paid checkout.session.completed calls
InvoiceService.recordOneOffCheckoutPurchase, which upserts the buyer as a Customer and records a
paid, source: 'one_off' Invoice — again recording, never issuing, so there is no second charge.
Checkout’s own generated invoice supplies the number, hosted URL and PDF when there is one; when
there isn’t (or retrieving it fails), the sale is still recorded and deduped on the Checkout session
id instead. So both halves of “every buyer becomes a Customer” are covered — recurring and one-off.
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. |
