The NexaCloud->Odoo ledger now verifies every new invoice against its
SOURCE billing system before posting, instead of trusting NexaCloud's
unreliable created_at/status/paid_at:
- _fc_verify routes by stripe_invoice_id prefix (in_ -> Stripe REST,
lago: -> Lago REST) and returns source-truth
{invoice_date, void, draft, paid, paid_at, amount_paid}, or None when it
can't be determined/reached (left for the next run).
- _ingest_invoices(post=True, verified=...) uses the source invoice date
(and accounting date), and reconciles a payment ONLY when the source
confirms paid.
- _cron_sync_verified posts only finalized invoices; skips void + draft,
logs unverified for retry. Replaces the old _cron_ingest_recent.
Cron cron_fc_invoice_ledger is enabled daily on nexamain. First live run:
23 already-posted, 1 void + 2 Stripe drafts + 5 zero-amount all skipped,
0 new posted, ledger intact at $3,403.46.
Tests: routing/guards (no network), verified date+reconcile, and the cron's
void/draft/unverified filtering (sources patched). FCB_EXIT=0 on odoo-trial.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
10 KiB
Markdown
159 lines
10 KiB
Markdown
# 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`).
|
|
|
|
## 10. Backfill status + go-forward caveat (2026-05-27)
|
|
|
|
- **Backfill done + verified on nexamain.** 23 customer invoices posted + payment-reconciled
|
|
($3,403.46), 1 void deleted. NexaCloud's `created_at`/`status`/`paid_at` proved
|
|
**unreliable** (sync-stamped today; one void marked otherwise), so invoice + payment dates
|
|
and paid status were verified against the **source systems**:
|
|
- **Stripe** (14 invoices, `in_*` ids) — real `created` / `paid_at` via the Stripe API.
|
|
- **Lago** (9 `NEX-*` invoices, `lago:*` ids, billed pre-Stripe) — `issuing_date` +
|
|
`payment_status=succeeded` via the Lago API (`billing.nexasystems.ca/api/api/v1`, key in
|
|
Fusion-Chat; Lago host 192.168.1.117, double-hop ssh via supabase-prod).
|
|
Partner names came from the NexaCloud `company` field (not the user's full_name).
|
|
- **GO-FORWARD: verified sync is LIVE (2026-05-27).** The verification used in the backfill
|
|
is now folded into the ingest path, and the daily cron is enabled:
|
|
- `_fc_verify(inv)` routes each invoice to its source by `stripe_invoice_id` prefix
|
|
(`in_` → Stripe REST `GET /v1/invoices/{id}`; `lago:` → Lago REST) and returns
|
|
`{invoice_date, void, draft, paid, paid_at, amount_paid}` taken from the SOURCE — or
|
|
`None` if it can't be determined/reached. Credentials live in `ir.config_parameter`:
|
|
`fusion_billing.stripe_api_key` (set, live), `fusion_billing.lago_api_url` /
|
|
`fusion_billing.lago_api_key` (optional; unset — no new Lago invoices expected).
|
|
- `_cron_sync_verified()` reads all NexaCloud invoices, skips ones already posted, then
|
|
for the rest: skips **void** and **draft** (not finalized at source), logs **unverified**
|
|
for retry next run, and ingests the rest with `_ingest_invoices(post=True, verified=…)`
|
|
so the move uses the source invoice_date (accounting date too) and a payment is
|
|
reconciled ONLY when the source confirms paid. Never acts on NexaCloud's raw fields.
|
|
- Cron `cron_fc_invoice_ledger` on nexamain: **active**, daily at 06:00 UTC. (A stale
|
|
pre-existing copy of this record still called the removed `_cron_ingest_recent`; because
|
|
the data file is `noupdate="1"` the upgrade didn't rewrite it, so its server-action code
|
|
+ name were corrected once via SQL. Fresh installs get the right definition from the XML.)
|
|
- First live run (2026-05-27): 23 already-posted, 1 void + 2 Stripe drafts + 5 genuine
|
|
$0 invoices all correctly skipped, **0 new posted**, ledger intact at $3,403.46.
|
|
- Verification helpers are unit-tested without network (routing short-circuits when no
|
|
credentials are set; the cron is exercised with `_read_nexacloud_invoices` / `_fc_verify`
|
|
patched). Full suite green on odoo-trial (`FCB_EXIT=0`).
|