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, 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 French eu_vat tax id (FR + key + SIREN). 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.

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.

“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:

MechanismPayer → payeeStripe accountLocal rowsCadenceTrigger
Platform plan (Pro)Org → Viiteviite’s connected accountentitlementsrecurrent (Stripe-driven)POST /api/entitlements/checkout → customer.subscription.* on the offers webhook
Credit top-upUser → Viiteviite’s connected accountcredit_transactionsone-offPOST /api/credits/checkout → checkout.session.completed on the offers webhook
Admin grant—noneentitlements (null Stripe ids)never expiresPOST /api/entitlements/admin/orgs/:orgId/grant-permanent
One-off invoiceCustomer → Orgthe org’s connected accountinvoices (source: one_off)one-offPOST /api/invoicing/deals/:dealId/invoice (draft) + finalize
Recurring invoiceCustomer → Orgthe org’s connected accountinvoices (source: recurring)month/year, our schedulergenerate_invoices claims due schedules
Offer checkoutBuyer → Orgthe org’s connected accountcustomers + invoices (recorded, never issued)one-off or recurrentpublic 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.

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

  • Subscriber — an organisation paying Viite for its own access. An entitlements row, 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 customers either 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.

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 '@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). 422 without 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.

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 hasThe invoice is issued with
a future due datethat date, to the second
no due dateissue date + 30 days (France’s legal default; the standard B2B term)
a date in the past, or less than an hour aheaddue 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)”).

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; the invoice’s unsent payment-reminder cards close
invoice.voided / invoice.marked_uncollectibleInvoice.status → uncollectible (distinct from an org-initiated void — a processor-side write-off after failed collection); its unsent payment-reminder cards close
invoice.payment_failedLogged only (v1) — Stripe’s own Smart Retries handle the retry

Both handlers are idempotent — safe under webhook redelivery.

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):

  1. every morning it lists the org’s overdue invoices (list_overdue_invoices, at least 3 days late);
  2. 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);
  3. 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)).

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):

RouteDoes
GET /?month=YYYY-MMThe month’s business days and holidays, plus one row per mission: its report, worked half days and amount.
PUT /:dealId/:monthIdempotent upsert of the days off ({ daysOff }). 409 once the invoice is no longer a draft.
GET /:dealId/:month/pdfThe 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.

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 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/health → systemSchedulesHealthy (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 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.

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') 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.

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.