28 · Core Invoicing — real-world readiness

Standalone deep review of the money backbone for a multi-tenant platform that many businesses will run in production. Honest score: strong architecture, partial implementation. Not yet “drop-in enterprise billing OS” without a hardening pass.

Live tables exist (Mario’s) Sprint A/B landed 2026-07-22 More product layer still open Reviewed 2026-07-22

Executive verdict (real-world platform)

DimensionScoreNotes
Architecture intentA−“Everything is an Invoice” is correct for a Business OS
Tenant data modelB+Per-tenant Invoices + line items + Payments + void/refund logs exist
POS → invoice pathBFinalize calls Core Invoicing; return-shape + paid_at bugs; fail-open on error
Partial / multi-tenderDMetadata can store multi-pay; processPayment always marks fully paid
Void / refundD+UI + routes exist; INSERT shapes mismatch live required columns
Ticket → billable invoiceC−Bill API solid; posInvoicing.js missing so checkout often creates no invoice
Multi-tenant API safetyCBody can supply tenantSlug/orgId; analytics routes hardcode mariospizza
Concurrency / numberingDInvoice numbers = COUNT+1; no transactions / idempotency on create
Crypto invoice (core path)FMock address + mock rates in Core path (real crypto is separate adapter stack)
Overall production readinessB− after Sprint A/BCore writers hardened (tx, partial pay, void/refund schema, tenant isolation, ticket bridge). Product AR/PDF/export and dual-path cleanup still open.
Bottom line Keep Core Invoicing as the spine. Do not invent a second money system. Invest in hardening this service until every sale, void, refund, and ticket bill is atomic, tenant-scoped, partially payable, and auditable.

What is already well thought through

  • Single money language — cents, org_id, tenant_id, source, type, status, line items table (not only JSON blob).
  • Factory methods for POS / kiosk / service ticket / inventory adjustment / crypto (intent is right even where crypto is stubbed).
  • POS finalize integration — cart → line items, multi-tender breakdown into payment_details, customer + loyalty metadata.
  • Manager UITransactionManagement voids/refunds via /api/core-invoicing/*.
  • List path for owners/api/transactions/list reads tenant Invoices + line items with org_id filter.
  • Void/refund table design — live schema is richer than the service (reason_code, approval fields, enums) — good DB design waiting for correct writers.
  • Live Mario’s data — hundreds of invoices with sources pos / kiosk / service_ticket proves the tables are exercised.

Live schema truth (dev-mariospizza, sampled)

TableStatusRole
Invoices✅ 27 colsCanonical sale/service record (no lines col — line items separate)
InvoiceLineItems✅ 26 colsSKU, tax, void flags, modifiers JSON
Payments✅ snake_caseinvoice_id, amount_cents, provider, status…
VoidAuditLog✅ requires reason_codeService INSERT currently omits required fields
RefundAuditLog✅ different columns than serviceService writes org_id/refunded_by… — will fail on live schema
AuditEvents✅ on main DBCore service writes here; analytics audit_logs is separate
transaction_tracking❌ missing on Mario’sDocs claim timeline table; not provisioned on this tenant
SalesAnalytics✅ main DBAlso analytics DB path elsewhere — dual aggregation risk

Sample counts (Mario’s): ~282 invoices · ~407 line items · 62 Payments · 5 voids · 0 refunds · ~75 paid invoices with paid_at IS NULL · ~19 invoices with zero line items.

Intended vs actual flows

flowchart TB
  subgraph GOOD["Working / mostly working"]
    POS[POS finalize] --> CIS[CoreInvoicingService.createPOSSaleInvoice]
    CIS --> INV[(Invoices + InvoiceLineItems)]
    TM[TransactionManagement UI] --> VOIDAPI["/void-invoice · /refund-invoice"]
    LIST["/api/transactions/list"] --> INV
  end

  subgraph BROKEN["Broken or incomplete"]
    TIX[Ticket bill/checkout] --> MISSING["posInvoicing.js MISSING"]
    MISSING -.->|returns null| NOINV[Often no Core invoice]
    CIS -.->|return shape| SHAPE["returns invoiceId not result.invoice"]
    SHAPE --> LOY[Loyalty/invoice id linkage fragile]
    PROC[processPayment] --> PAID["always status=paid"]
    VOIDAPI --> SCH["Void/RefundAuditLog schema mismatch"]
    TENDER[POS tender INSERT Payments] --> CAMEL["camelCase columns ≠ live snake_case"]
  end
          

Landed in git (feat/core-invoicing-hardening → main)

  • DB transactions for create / pay / void / refund
  • paid_at / paid_by when status=paid; correct return status + nested invoice
  • Partial payments → partially_paid until balance 0
  • Multi-tender rows written into Payments on paid create
  • Void/RefundAuditLog INSERTs match live required columns
  • API tenant context from session only (no body tenant hijack)
  • Analytics GETs use real req.tenant.slug (no mariospizza hardcode)
  • posInvoicing.js + ticket checkout/mark-paid wire to Core
  • POS creates invoice before cart status=paid; fail-closed default (CORE_INVOICE_FAIL_CLOSED)
  • Safer per-org invoice numbering with row lock
  • utils/posPayments.js — snake_case Payments helper; tender deferred until invoice (FK); finalize ensure idempotent
  • /api/invoices rewritten as Core Invoicing facade (no Sequelize dual write)
  • DocHub pages 14–26 committed to git
  • refundRails.service.js — gift credit, loyalty reverse (best-effort), inventory stock_ledger return, card/crypto manual notes
  • POS finalize idempotent by metadata.cartId (one cart → one invoice)
  • Card gateway refund: Stripe pi_/ch_ when secret present; mock/PAWS simulate; else manual_required
  • Store credit / gift refund method issues GiftCards code (SC-…)
  • GET /api/core-invoicing/export CSV (or JSON) for accountant export
  • Transaction Management UI: Export CSV + rails summary after refund

Smoke-tested on Mario’s through refund rails, store credit issue, card simulate, export path, cart idempotency.

Remaining gaps (after Sprint A/B)

P0 — Correctness & money integrity

  1. Return shape mismatch (POS finalize)
    Service returns { success, invoiceId, invoiceNumber, totalCents, status }. POS does invoice = result.invoice → always undefined. Downstream loyalty / metadata updates using invoice.id silently no-op.
  2. status returned as DRAFT even when paid
    createInvoice writes provided status to DB but return object hardcodes tenantConfig.INVOICE_STATUSES.DRAFT.
  3. paid_at not set on create when status=paid
    POS passes status paid; INSERT never sets paid_at/paid_by → 75+ paid rows with null paid_at on Mario’s.
  4. processPayment always fully pays
    No balance check vs invoice total, no sum of prior Payments, no partial/pending status. Multi-tender only lives in JSON metadata from cart — not as proper payment allocation.
  5. Invoice create failure is fail-open on POS
    Cart finalizes; inventory/analytics may run; invoice missing → money spine hole. Real businesses need fail-closed or durable outbox retry.
  6. No DB transaction around invoice + lines + payment + audit
    Partial writes possible under errors (invoice without lines, etc.).
  7. Invoice number race
    COUNT(*)+1 is not safe under concurrent POS terminals. Need sequence/row lock or unique retry.

P0 — Void / refund actually work against live schema

  1. VoidAuditLog requires reason_code (NOT NULL). Service only inserts invoice_id, void_type, amount, reason_description, staff — missing reason_code → insert fails → whole void may roll back mid-path.
  2. RefundAuditLog service columns (org_id, refunded_by, reason, refund_amount_cents, is_full_refund, metadata) do not match live table (refund_type, amount_refunded_cents, reason_code, reason_description, refunded_by_staff_id, refund_method enum). Explains 0 refund rows on Mario’s despite refund UI.
  3. Refund does not create negative Payments, reverse card/crypto/gift, restock inventory, or reverse loyalty.

P1 — Multi-tenant & API hardening

  1. Create routes accept tenantSlug / orgId / tenantId from request body instead of only req.tenant. Authenticated user could target another tenant if auth is weak.
  2. Sales/inventory analytics endpoints hardcode tenantSlug: 'mariospizza' — broken for every other business.
  3. getInvoiceById / void / processPayment do not always re-check org_id ownership after load by id.
  4. Dual requireAuth (route-local) vs requireSessionAuth (server mount) — OK if both fire, but staffId-only session paths need role checks on money ops.

P1 — Schema / writer consistency

  1. POS tender paths INSERT Payments with camelCase (orgId, amountCents…) — live table is snake_case. Failures are caught and logged; Payments often never attached at tender time.
  2. Core create path does not create Payments rows for POS multi-tender (only payment_details JSON). processPayment can create one row — not N tenders.
  3. Sequelize Invoice model + /api/invoices looks like master/ORM path (lines JSON) — parallel to tenant Core Invoicing. Confusing dual write surface for “many businesses.”

P2 — Product completeness for real SMBs

  • Due dates, AR aging, send-invoice email/SMS, PDF customer invoice (B2B service shops).
  • Tax jurisdiction per line / location (rate engine beyond cart-level tax).
  • Store credit / gift refund rails that hit real balances.
  • Idempotency keys on create/pay/void (retry-safe terminals).
  • Per-tenant numbering prefixes, fiscal year resets, location sequences.
  • Core crypto path is mock; real DOGE/LTC/BTC lives in payments adapters — must merge spine.
  • Tenant config stub: getTenantInvoicingConfig always returns env defaults.

Multi-tenant safety checklist

RuleToday
Resolve tenant only from session/host, never client body❌ body overrides on create routes
Every SELECT/UPDATE includes org_id⚠️ some by id only
Money mutations require role (manager+) + reason✅ void/refund role gate; create less strict
Cross-tenant analytics impossible❌ hardcoded mariospizza slug on analytics GETs
Secrets / credentials never in invoice metadataOK if callers careful
7-year retention story for auditConfig says 2555 days; enforcement not proven

Financial correctness model (target)

stateDiagram-v2
  [*] --> draft
  draft --> pending: issue / send
  draft --> paid: POS immediate (full tender)
  pending --> partial: payment < balance
  partial --> paid: balance = 0
  pending --> paid: full payment
  paid --> partially_refunded: partial refund
  paid --> refunded: full refund
  draft --> void: void before pay
  pending --> void: void unpaid
  paid --> void: void only with reverse payments + policy
          

Balance rule: balance_cents = total_cents − SUM(succeeded Payments) + SUM(refunds). Status is derived or carefully updated after each payment/refund — never blindly set to paid.

Ticket billable invoices — real state

Gap tickets.routes.js calls optional services/posInvoicing.jscreatePosInvoiceForTicket. That file does not exist. Checkout returns invoiceId null; mark-paid can set status without Core Invoice. Only ~4 service_ticket invoices on Mario’s (likely manual/seed).

Fix: implement createPosInvoiceForTicket as a thin wrapper around CoreInvoicingService.createServiceTicketInvoice (+ processPayment or paid status), using session tenantSlug/orgId — never optional/missing.

Ticket crypto path uses CryptoInvoice model — parallel track; should eventually link to Core Invoice id for owner reporting.

Parallel money paths to retire or unify

PathAction
CoreInvoicingService + tenant InvoicesCanonical — keep
/api/invoices Sequelize Invoice (lines JSON)Route to Core or deprecate for tenant sales
InvoiceService writing order tableDo not treat as financial truth
CryptoInvoice adaptersKeep for chain watch; always link Core Invoice
POS tender Payments camelCase insertsAlign to Core Payments snake_case + invoice_id
Multiple transaction list routersOne owner UI reading Invoices only

Production checklist for “many businesses”

#CapabilityNeed
1Atomic create (invoice + lines + payments + audit)Required
2Idempotent finalize (cart → one invoice)Required
3Safe invoice numbering per org/locationRequired
4Partial payments + balanceRequired for AR / split tender
5Void with reason codes + manager approval fieldsRequired
6Refund with rail (cash/card/gift/store credit) + inventory/loyalty reverseRequired
7Ticket bill → Core invoice alwaysRequired for service verticals
8Tenant isolation (session only, org_id everywhere)Required
9paid_at / staff / device / location on every paid saleRequired for disputes
10Export (CSV/PDF) for accountantHigh
11Customer-facing AR invoice sendVertical-dependent
12Fiscal / tax report hooksHigh

Prioritized fix order (recommended)

  1. Hardening sprint A (1–2 days) — Return shape + paid_at + correct status return; wrap create in transaction; org_id on all mutations; kill body tenant overrides; fix analytics slug; POS fail policy (retry queue or fail closed).
  2. Hardening sprint B — Align void/refund INSERTs to live VoidAuditLog/RefundAuditLog; create Payments rows for each tender; processPayment with balance math; map refund_method enums.
  3. Ticket sprint — Implement posInvoicing.js → createServiceTicketInvoice; mark-paid must processPayment; refuse mark-paid without invoice when policy requires.
  4. Numbering & concurrency — Sequence table or SELECT FOR UPDATE counter; unique invoice_number + retry.
  5. Unify dual paths — Deprecate Sequelize /api/invoices for tenant sales or rewrite as Core facade; fix POS Payments column names.
  6. Product layer — PDF/email AR, export, per-tenant config in DB, real crypto address link.
Do not rebuild from scratch The spine (Invoices + InvoiceLineItems + Payments + manager UI + POS call site) is the right shape. Fix the writers and invariants; ship as the financial OS for every tenant.

Key files

  • backend/services/coreInvoicing.service.js — spine implementation (~909 lines)
  • backend/routes/coreInvoicing.routes.js — HTTP API
  • backend/config/invoicingConfig.js — statuses, providers (tenant overrides stubbed)
  • backend/routes/pos.routes.js — finalize → createPOSSaleInvoice
  • backend/routes/tickets.routes.js — bill/* + missing posInvoicing import
  • backend/routes/transactions.routes.js — owner list from Invoices
  • frontend/.../TransactionManagement.jsx — void/refund UI
  • backend/routes/invoices.routes.js — parallel Sequelize path
  • backend/models/invoice.model.js — ORM model (lines JSON)
  • DocHub 05 money spine · DocHub 27 auditability