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.
Executive verdict (real-world platform)
| Dimension | Score | Notes |
|---|---|---|
| Architecture intent | A− | “Everything is an Invoice” is correct for a Business OS |
| Tenant data model | B+ | Per-tenant Invoices + line items + Payments + void/refund logs exist |
| POS → invoice path | B | Finalize calls Core Invoicing; return-shape + paid_at bugs; fail-open on error |
| Partial / multi-tender | D | Metadata can store multi-pay; processPayment always marks fully paid |
| Void / refund | D+ | UI + routes exist; INSERT shapes mismatch live required columns |
| Ticket → billable invoice | C− | Bill API solid; posInvoicing.js missing so checkout often creates no invoice |
| Multi-tenant API safety | C | Body can supply tenantSlug/orgId; analytics routes hardcode mariospizza |
| Concurrency / numbering | D | Invoice numbers = COUNT+1; no transactions / idempotency on create |
| Crypto invoice (core path) | F | Mock address + mock rates in Core path (real crypto is separate adapter stack) |
| Overall production readiness | B− after Sprint A/B | Core writers hardened (tx, partial pay, void/refund schema, tenant isolation, ticket bridge). Product AR/PDF/export and dual-path cleanup still open. |
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 UI —
TransactionManagementvoids/refunds via/api/core-invoicing/*. - List path for owners —
/api/transactions/listreads tenantInvoices+ 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)
| Table | Status | Role |
|---|---|---|
Invoices | ✅ 27 cols | Canonical sale/service record (no lines col — line items separate) |
InvoiceLineItems | ✅ 26 cols | SKU, tax, void flags, modifiers JSON |
Payments | ✅ snake_case | invoice_id, amount_cents, provider, status… |
VoidAuditLog | ✅ requires reason_code | Service INSERT currently omits required fields |
RefundAuditLog | ✅ different columns than service | Service writes org_id/refunded_by… — will fail on live schema |
AuditEvents | ✅ on main DB | Core service writes here; analytics audit_logs is separate |
transaction_tracking | ❌ missing on Mario’s | Docs claim timeline table; not provisioned on this tenant |
SalesAnalytics | ✅ main DB | Also 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_bywhen status=paid; correct return status + nestedinvoice- Partial payments →
partially_paiduntil balance 0 - Multi-tender rows written into
Paymentson 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/invoicesrewritten as Core Invoicing facade (no Sequelize dual write)- DocHub pages 14–26 committed to git
refundRails.service.js— gift credit, loyalty reverse (best-effort), inventorystock_ledgerreturn, 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
GiftCardscode (SC-…) GET /api/core-invoicing/exportCSV (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
-
Return shape mismatch (POS finalize)
Service returns{ success, invoiceId, invoiceNumber, totalCents, status }. POS doesinvoice = result.invoice→ always undefined. Downstream loyalty / metadata updates usinginvoice.idsilently no-op. -
status returned as DRAFT even when paid
createInvoice writes provided status to DB but return object hardcodestenantConfig.INVOICE_STATUSES.DRAFT. -
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. -
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. -
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. -
No DB transaction around invoice + lines + payment + audit
Partial writes possible under errors (invoice without lines, etc.). -
Invoice number race
COUNT(*)+1is not safe under concurrent POS terminals. Need sequence/row lock or unique retry.
P0 — Void / refund actually work against live schema
-
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. -
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. - Refund does not create negative Payments, reverse card/crypto/gift, restock inventory, or reverse loyalty.
P1 — Multi-tenant & API hardening
-
Create routes accept
tenantSlug / orgId / tenantIdfrom request body instead of onlyreq.tenant. Authenticated user could target another tenant if auth is weak. -
Sales/inventory analytics endpoints hardcode
tenantSlug: 'mariospizza'— broken for every other business. -
getInvoiceById/ void / processPayment do not always re-check org_id ownership after load by id. - 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
-
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. - Core create path does not create Payments rows for POS multi-tender (only payment_details JSON). processPayment can create one row — not N tenders.
-
Sequelize
Invoicemodel +/api/invoiceslooks 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:
getTenantInvoicingConfigalways returns env defaults.
Multi-tenant safety checklist
| Rule | Today |
|---|---|
| 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 metadata | OK if callers careful |
| 7-year retention story for audit | Config 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
tickets.routes.js calls optional services/posInvoicing.js →
createPosInvoiceForTicket. 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
| Path | Action |
|---|---|
CoreInvoicingService + tenant Invoices | Canonical — keep |
/api/invoices Sequelize Invoice (lines JSON) | Route to Core or deprecate for tenant sales |
InvoiceService writing order table | Do not treat as financial truth |
| CryptoInvoice adapters | Keep for chain watch; always link Core Invoice |
| POS tender Payments camelCase inserts | Align to Core Payments snake_case + invoice_id |
| Multiple transaction list routers | One owner UI reading Invoices only |
Production checklist for “many businesses”
| # | Capability | Need |
|---|---|---|
| 1 | Atomic create (invoice + lines + payments + audit) | Required |
| 2 | Idempotent finalize (cart → one invoice) | Required |
| 3 | Safe invoice numbering per org/location | Required |
| 4 | Partial payments + balance | Required for AR / split tender |
| 5 | Void with reason codes + manager approval fields | Required |
| 6 | Refund with rail (cash/card/gift/store credit) + inventory/loyalty reverse | Required |
| 7 | Ticket bill → Core invoice always | Required for service verticals |
| 8 | Tenant isolation (session only, org_id everywhere) | Required |
| 9 | paid_at / staff / device / location on every paid sale | Required for disputes |
| 10 | Export (CSV/PDF) for accountant | High |
| 11 | Customer-facing AR invoice send | Vertical-dependent |
| 12 | Fiscal / tax report hooks | High |
Prioritized fix order (recommended)
- 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).
- 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.
-
Ticket sprint — Implement
posInvoicing.js→ createServiceTicketInvoice; mark-paid must processPayment; refuse mark-paid without invoice when policy requires. - Numbering & concurrency — Sequence table or SELECT FOR UPDATE counter; unique invoice_number + retry.
- Unify dual paths — Deprecate Sequelize /api/invoices for tenant sales or rewrite as Core facade; fix POS Payments column names.
- Product layer — PDF/email AR, export, per-tenant config in DB, real crypto address link.
Key files
backend/services/coreInvoicing.service.js— spine implementation (~909 lines)backend/routes/coreInvoicing.routes.js— HTTP APIbackend/config/invoicingConfig.js— statuses, providers (tenant overrides stubbed)backend/routes/pos.routes.js— finalize → createPOSSaleInvoicebackend/routes/tickets.routes.js— bill/* + missing posInvoicing importbackend/routes/transactions.routes.js— owner list from Invoicesfrontend/.../TransactionManagement.jsx— void/refund UIbackend/routes/invoices.routes.js— parallel Sequelize pathbackend/models/invoice.model.js— ORM model (lines JSON)- DocHub 05 money spine · DocHub 27 auditability