Files
Odoo-Modules/docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md
gsinghpal bf6ee2bb2c docs(billing): design spec — NexaCloud invoice ledger (Odoo as accounting SoR)
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).
2026-05-27 16:33:46 -04:00

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 on nexamain)
  • 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.move customer 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-only psycopg2: invoices + invoice_items (+ users for 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 one account.move (move_type='out_invoice') with lines, tax, and (if paid) a reconciled payment. Idempotent on x_fc_nexacloud_invoice_id. Returns a summary. With post=False invoices are left draft; a separate _post_ingested(...) bulk-posts after review.
  • Trigger: an account.move-creation wizard/action + a daily ir.cron for ongoing.

4. Data mapping

4.1 Invoice → account.move

  • move_type='out_invoice', partner_id = unified res.partner (resolve invoice.user_idaccount.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_itemaccount.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 Odoo account.taxHST 13% when ≈13%, no tax when 0, else the closest configured tax. Odoo's computed tax must equal NexaCloud's invoice.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' (or amount_paid >= amount_due): register an account.payment via a "NexaCloud Stripe" bank journal, dated paid_at, amount amount_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 on account.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.cron calls _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)

  1. Build + TDD on odoo-trial (fixture invoices → assert move totals, tax = source tax, payment reconciled, idempotency, family→account mapping).
  2. Dry-run mode (read + report, write nothing) — like the importer.
  3. First nexamain run: ingest all history as DRAFT, report a summary (counts per family, total $, unmatched-"Other" lines, tax mismatches). You review a sample.
  4. Bulk-post after approval. Then enable the daily cron.
  5. Prune the obsolete metered shadow data first: delete the 87 draft shadow sale.orders (x_fc_shadow=True), the ~464 NC-* 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.payment field names + the post flow (action_post) and payment register/reconcile API (read account + account_accountant reference on odoo-trial).
  • The HST account.tax record + income accounts + a usable bank journal on nexamain (from nexa_coa_setup); create the "NexaCloud Stripe" journal + family income accounts if absent.
  • Whether invoice_items.amount is pre-tax (expected: invoice.subtotal = Σ items; tax separate).

9. Success criteria

  • A fixture NexaCloud invoice ingests to a posted account.move whose untaxed total, tax (= source invoice.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).