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, company registration number — the SIREN in France, printed on the invoice as
its own “SIREN client” line — and an optional
currency). Each issue also refreshes the processor-side customer’s name, address and missing VAT number, since Stripe copies them onto the invoice at finalize. The seller’s SIREN prints as “SIREN émetteur”, read off the connected account’s own Frencheu_vattax id (FR + key + SIREN). 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/deals/:dealId/invoice (draft) + 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), raised from a CRM deal:const invoice = await client.invoices.createOneOff({ customerId: acme.id, offerId: studio.id, sourceDealTaskId: deal.id,});// draft -> open, issued at Stripe WITHOUT Stripe's email, then sent from YOUR connected Gmail:const { delivery } = await client.invoices.finalizeAndSend(invoice.id);// Again (after a failed mail, or to a corrected address — this mail only):await client.invoices.send(invoice.id, { to: 'billing@acme.example' });
// One-off, hand-written line items (no Offer behind it). A quantity is positive// with at most two decimals — 3, or 20.5 days; the line is quantity × unit price,// rounded half up to the cent:await client.invoices.createOneOff({ customerId: acme.id, lineItems: [{ description: 'Ad-hoc consulting', quantity: 3, unitAmountCents: 15000 }], sourceDealTaskId: deal.id,});
// 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);Every one-off invoice is raised from a deal, through ONE action.
POST /api/invoicing/deals/:dealId/invoice (client.invoices.createForDeal(dealId, { month? }); the
deal’s Create invoice, Billing → New invoice, the CRA grid and the CRA card all call it)
drafts the deal’s invoice, and the server decides what it is from one criterion — is the deal a
mission (won, with a daily rate)?
- Mission → the month’s activity-report invoice (see Monthly activity report):
month(YYYY-MM, default the current month in Paris) picks the month; the report card is created if needed, the invoice is linked to it and a draft is re-priced, never duplicated. That link is what mails the CRA PDF on finalize. - Any other deal → one line, the deal’s title × its amount, VAT 20 % (edit it on the draft).
422without an amount. A deal that already has a draft gets that draft back (reused: true), never a second one; once it is finalized, the next call drafts a new one.
It answers { kind: 'activity_report' | 'plain', invoice, … } and never finalizes or sends.
The raw create, POST /api/invoicing, stays for API callers. It requires sourceDealTaskId
and answers 400 with the reason unless it resolves to a deal you can see that is linked to
exactly one company (company_of) and at least one contact (deal_contact or contact_of).
That pointer is how the deal hears about its money: the deal’s timeline gets an “Invoice … drafted”
line on create and an “Invoice … paid” line, once, when Stripe’s invoice.paid arrives. Recurring
invoices from a schedule and mirrored Stripe subscription invoices are not raised by a person and
are not checked. It refuses a mission deal with 409 — a plain invoice there would race the
month’s CRA invoice (two invoices for one month, the finalized one unlinked from its report).
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/send ({ to? }),
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'), which mints a hosted invoice page and a PDF,
both linked back onto our Invoice row (stripeHostedInvoiceUrl/stripeInvoicePdfUrl).
Who emails it. When a person finalizes, Stripe does NOT email the customer: the invoice is sent
from that person’s own connected Gmail (the gmail:// channel — it lands in their Sent folder,
and the customer’s reply reaches them). One mail carries the payment link, Stripe’s PDF and, for an
invoice raised from an activity report, the CRA PDF. With no Gmail connected, finalize is refused
before anything is issued (409, code: 'gmail_not_connected'); a mail that fails after issuing
does not fail the finalize — the response’s delivery says so and POST /:id/send sends it again.
The recurring schedules (generate_invoices, a cron) keep Stripe’s own email: a node never mails
a customer itself, and has no person’s mailbox to send from.
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 and mails nobody.
One number, one due date
Section titled “One number, one due date”The ledger is the single source of truth for both. Stripe is told what to show, never asked.
Number. Our INV-<org>-<year>-<seq> is sent as Stripe’s own number when the draft is created, so the Stripe
email, the hosted page, the PDF and our reminder mail all carry the same number. Checked in Stripe test mode on a
connected account: accepted on a draft, kept verbatim through finalize, the customer’s invoice_prefix is not
prepended, at most 26 characters (ours are 24), and Stripe’s own sequence for any other invoice is untouched. If Stripe
refuses the number itself (too long, malformed), the invoice is still issued and our number rides in a custom field
named Invoice no.. That fallback is weaker: Stripe’s hosted page does not render custom fields (checked), so the page
shows Stripe’s own number there — which is why the reminder looks the page’s number up (below). A duplicate is different: it means an earlier attempt may already have issued this invoice,
so it is refused with an explanation rather than retried — a second, number-less invoice would bill the customer twice.
Any failure after the draft exists deletes the draft (it holds the number, so a leftover would block the retry).
Due date. At finalize the due date is decided once and written back to the ledger, then sent to Stripe as an
absolute due_date (not days_until_due, which Stripe floors at one day):
| The draft has | The invoice is issued with |
|---|---|
| a future due date | that date, to the second |
| no due date | issue date + 30 days (France’s legal default; the standard B2B term) |
| a date in the past, or less than an hour ahead | due on receipt — an hour from now, because Stripe cannot show a past date |
So the overdue list and the payment reminders read the date the customer was shown. Stripe renders the date in the connected account’s time zone; a date-only value is midnight UTC.
Invoices issued before this change carry Stripe’s own number on the hosted page (e.g. NANJ7725-0003) while the
ledger says INV-…. Nothing is rewritten on a finalized invoice. The approved payment reminder asks Stripe for the
number on the page once, when it is sent, and shows both: Invoice NANJ7725-0003 (our reference INV-…). If the lookup
fails, the ledger number is quoted and the reminder still goes.
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; the invoice’s unsent payment-reminder cards close |
invoice.voided / invoice.marked_uncollectible | Invoice.status → uncollectible (distinct from an org-initiated void — a processor-side write-off after failed collection); its unsent payment-reminder cards close |
invoice.payment_failed | Logged only (v1) — Stripe’s own Smart Retries handle the retry |
Both handlers are idempotent — safe under webhook redelivery.
Chasing unpaid invoices
Section titled “Chasing unpaid invoices”Overdue is status = open and dueDate < now — the same rule as the money tiles. Stripe’s own dunning is off on
purpose (auto_advance: false: our status machine owns the ledger), so nothing chased an overdue invoice until the
Chase unpaid invoices workflow template (Workflows → Use template):
- every morning it lists the org’s overdue invoices (
list_overdue_invoices, at least 3 days late); - a model drafts one polite reminder per invoice in your voice (pick your skill on the Draft the reminder node),
firmer at day 3, day 10 and day 20 (
draft_invoice_reminder); - each draft is filed as a card on your board (
file_invoice_reminder) — one per invoice and step, never twice, and a firmer reminder replaces a softer one you never sent.
Nothing is sent until you approve. Open the card, edit the draft if you like, save, then Approve and send (two
presses; the second names the address). Only a signed-in organisation admin can — an API key or Claude cannot. The mail
goes to the invoice’s own billing contact with the Stripe hosted invoice link appended from the ledger (a link the model
wrote would be stripped) and the invoice number as it reads on that page, Reply-To is you, and the reminder is logged on the customer’s contact card. When the invoice
is paid, voided or written off, its unsent reminder cards close themselves, and approving one that slipped through is
refused after a fresh read of the ledger. Needs RESEND_API_KEY (and a verified SMTP_FROM domain).
To dismiss a reminder you do not want, archive its card — do not delete it. The card is what remembers that this invoice was already handled at this step; a deleted card is forgotten, and tomorrow’s run would draft it again.
POST /api/invoicing/reminders/:taskId/send is the one endpoint (SDK invoices.approveReminder(taskId)).
Monthly activity report (CRA)
Section titled “Monthly activity report (CRA)”A freelancer bills days worked, not a fixed price. Put a daily rate on a won deal
(dailyRateCents in its deal payload, also settable through MCP update_task → deal) and that
deal becomes a mission. Each month it gets one activity-report card, keyed
cra:<org>:<deal>:<YYYY-MM> and linked part_of its deal. The card stores only the days off
(0.5 or 1 per day); worked days are derived as the month’s business days (Monday to Friday,
minus French public holidays) minus those days off, counted in half days.
Under /api/invoicing/activity-reports (reads: any member; writes: org owner/admin):
| Route | Does |
|---|---|
GET /?month=YYYY-MM | The month’s business days and holidays, plus one row per mission: its report, worked half days and amount. |
PUT /:dealId/:month | Idempotent upsert of the days off ({ daysOff }). 409 once the invoice is no longer a draft. |
GET /:dealId/:month/pdf | The month’s report as the PDF the client receives with its invoice. |
The month’s invoice is drafted by the one deal action, POST /api/invoicing/deals/:dealId/invoice
with { month }: ONE line (Prestation de conseil — octobre 2026 (20,5 j), quantity 20.5 at the
daily rate), or the existing draft re-priced. 422 with no worked day. It only ever creates a draft: a person finalizes it from Billing, and nothing is sent. The
report follows its invoice: finalized means invoiced and locked, voided reopens it to submitted.
The Rappel CRA mensuel workflow template (monthly-activity-report) runs prepare_activity_reports
on weekdays; only on the last business day of the month does it file the draft CRA card for each
mission that lacks one. The card in To-do is the reminder. It never creates an invoice.
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. |
