diff --git a/docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md b/docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md new file mode 100644 index 00000000..e9233af3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md @@ -0,0 +1,124 @@ +# 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_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 Odoo `account.tax` — **HST 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.order`s (`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`).