Pivot from recompute-metered-billing to INGEST NexaCloud's real Stripe invoices into Odoo account.move (posted + payment-reconciled + HST), driven by the dual-run finding that 94% of NexaCloud revenue is Stripe service invoices + add-ons + proration outside the per-deployment/CPU model. Full accounting SoR, all history + ongoing, revenue split by service family, draft-first rollout. Build/test on trial; reuses the read-only DSN + partner mapping. Supersedes the metered direction for NexaCloud (engine kept inert).
7.4 KiB
NexaCloud → Odoo Invoice Ledger (Design)
- Date: 2026-05-27
- Status: Design approved (brainstorming) — pending written-spec review
- Module:
fusion_centralize_billing(Odoo 19 Enterprise; build/test on odoo-trial, run onnexamain) - Supersedes (for NexaCloud): the metered-billing direction (recompute charges from a CPU-seconds model). The dual-run proved that model captures ~6% of reality.
1. Why this exists (the pivot)
The dual-run reconciliation (2026-05-27) showed 94% of NexaCloud's revenue is billed outside the per-deployment/CPU-metered model the engine was built for:
| NexaCloud invoices | count | total |
|---|---|---|
NOT linked to a subscriptions row (Hosting services, add-ons) |
22 | $2,881.08 |
Linked to a subscriptions row (what the metered importer reads) |
7 | $180.79 |
NexaCloud bills via Stripe — service invoices (Odoo ERP Hosting / WordPress Hosting ~$214.50/mo), add-ons (Daily Backup, WhatsApp, Forms Builder, White Label), and Stripe proration ("Remaining time on …"). That billing already works. Re-implementing Stripe's proration + add-on logic in Odoo is the wrong move. Instead, Odoo ingests NexaCloud's actual invoices and becomes the single accounting system of record (posted invoices + reconciled payments + HST), while NexaCloud/Stripe keep doing the billing.
2. Goal & scope (locked in brainstorming)
- Full accounting SoR: posted
account.movecustomer invoices, Stripe payments reconciled (invoices show paid, AR accurate), HST modelled. - All history + ongoing. Backfill every NexaCloud invoice, then a daily cron for new ones.
- Revenue split by service family into distinct income accounts (P&L breakdown).
- Draft-first rollout: first nexamain run creates drafts for review, then bulk-post.
3. Architecture
A new ingestion component in fusion_centralize_billing, mirroring the importer's
read/write split (reuses the read-only DSN + the account.link partner mapping already
set up on nexamain):
_read_nexacloud_invoices(since=None)— read-onlypsycopg2:invoices+invoice_items(+usersfor partner resolution), optionally since a date. Returns plain row dicts. The only code touching NexaCloud._ingest_invoices(data, post=False)— pure Odoo: for each NexaCloud invoice, upsert oneaccount.move(move_type='out_invoice') with lines, tax, and (if paid) a reconciled payment. Idempotent onx_fc_nexacloud_invoice_id. Returns a summary. Withpost=Falseinvoices are left draft; a separate_post_ingested(...)bulk-posts after review.- Trigger: an
account.move-creation wizard/action + a dailyir.cronfor ongoing.
4. Data mapping
4.1 Invoice → account.move
move_type='out_invoice',partner_id= unifiedres.partner(resolveinvoice.user_id→account.link(service=nexacloud) → partner; create via the importer's resolver if missing),invoice_date= NexaCloud invoice date,ref=invoice_number,currency_id= CAD.- New fields (x_fc_*) on
account.move:x_fc_nexacloud_invoice_id(idempotency key, unique),x_fc_stripe_invoice_id.
4.2 invoice_item → account.move.line (one per item)
name= item description,quantity,price_unit,account_id= the service-family income account (see 4.3).- Tax: derive the invoice's effective rate from
invoice.tax / invoice.subtotal; map to the matching Odooaccount.tax— HST 13% when ≈13%, no tax when 0, else the closest configured tax. Odoo's computed tax must equal NexaCloud'sinvoice.tax(assert in tests).
4.3 Service-family → income account (keyword mapping, with fallback)
| Family | Matches (description keywords) |
|---|---|
| Hosting | "Odoo ERP Hosting", "WordPress Website Hosting" |
| Managed plans | "Managed", "Managed Odoo - Standard", "… - Managed" |
| Add-ons | "Daily Backup Protection", "WhatsApp Business Messaging", "Forms Builder", "White Label Branding" |
| Proration | "Remaining time on …" → resolve to the family of the named item |
| Other (fallback) | anything unmatched → a generic NexaCloud income account (flagged in the summary for review) |
Income-account codes come from the COA (nexa_coa_setup); confirm/create at implementation.
4.4 Payment reconciliation
- For invoices with
status='paid'(oramount_paid >= amount_due): register anaccount.paymentvia a "NexaCloud Stripe" bank journal, datedpaid_at, amountamount_paid, ref =stripe_invoice_id; reconcile it against the posted invoice so the invoice shows paid and AR clears. - Open/unpaid invoices: post (or draft) without a payment → they sit in AR. Void invoices: ingest as cancelled (or skip) — decide from the data at implementation.
5. Idempotency & ongoing sync
- Upsert on
x_fc_nexacloud_invoice_id(a DB-unique field onaccount.move). Re-running updates a still-draft move or skips a posted one (never duplicates, never silently mutates a posted ledger entry — posted invoices that changed upstream are reported for manual review). - Daily
ir.croncalls_read_nexacloud_invoices(since=last_run)→_ingest_invoices(post=True)for go-forward invoices (configurable auto-post once trusted).
6. Safety & rollout (touches the live ledger)
- Build + TDD on odoo-trial (fixture invoices → assert move totals, tax = source tax, payment reconciled, idempotency, family→account mapping).
- Dry-run mode (read + report, write nothing) — like the importer.
- First nexamain run: ingest all history as DRAFT, report a summary (counts per family, total $, unmatched-"Other" lines, tax mismatches). You review a sample.
- Bulk-post after approval. Then enable the daily cron.
- Prune the obsolete metered shadow data first: delete the 87 draft shadow
sale.orders (x_fc_shadow=True), the ~464NC-*products, the NexaCloud charges, and the reconciliation rows — they belong to the superseded recompute approach and would confuse the ledger.
7. Out of scope
- The metered recompute engine's go-live (flip, control loop, usage push) — superseded for NexaCloud. The engine code stays in the module (potential future metered service, e.g. NexaMaps) but is inert.
- NexaDesk / NexaMaps ledgers — separate (same ingestion pattern when needed).
- Reproducing Stripe's billing logic — explicitly NOT done; we ingest its output.
8. Verify at implementation (Odoo 19; never from memory)
account.move/account.move.line/account.paymentfield names + the post flow (action_post) and payment register/reconcile API (readaccount+account_accountantreference on odoo-trial).- The HST
account.taxrecord + income accounts + a usable bank journal onnexamain(fromnexa_coa_setup); create the "NexaCloud Stripe" journal + family income accounts if absent. - Whether
invoice_items.amountis pre-tax (expected:invoice.subtotal = Σ items; tax separate).
9. Success criteria
- A fixture NexaCloud invoice ingests to a posted
account.movewhose untaxed total, tax (= sourceinvoice.tax), and total match the source; a paid one is reconciled and shows paid. - Re-running ingests nothing new (idempotent).
- Dry-run on nexamain reports the full backfill (counts per family, $ totals, unmatched lines) with zero writes; the real run creates drafts; bulk-post on approval.
- Full suite green on odoo-trial (
FCB_EXIT=0).