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>
10 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).
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_atproved 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) — realcreated/paid_atvia the Stripe API. - Lago (9
NEX-*invoices,lago:*ids, billed pre-Stripe) —issuing_date+payment_status=succeededvia 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 NexaCloudcompanyfield (not the user's full_name).
- Stripe (14 invoices,
- 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 bystripe_invoice_idprefix (in_→ Stripe RESTGET /v1/invoices/{id};lago:→ Lago REST) and returns{invoice_date, void, draft, paid, paid_at, amount_paid}taken from the SOURCE — orNoneif it can't be determined/reached. Credentials live inir.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_ledgeron 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 isnoupdate="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_verifypatched). Full suite green on odoo-trial (FCB_EXIT=0).