Files
Odoo-Modules/fusion_centralize_billing
gsinghpal 2bdf4ef6a0 feat(billing): 2d dual-run reconciliation (Odoo-computed vs NexaCloud-actual)
fusion.billing.reconciliation gains the compute: _compute_reconciliation
(flat + charge overage vs external, status match/delta at a tolerance) and
_reconcile_rows (resolve shadow sub -> flat + charge, upsert one row per
service/partner/period, per-row isolated). The wizard gains a read-only
_read_reconciliation_rows (NexaCloud usage cpu_hours*3600 + invoice-item
subtotals per YYYY-MM) and a "Run Reconciliation" button. 2a amended to
stamp x_fc_nexacloud_plan_id on shadow subs so reconciliation can find the
charge. Read-only on NexaCloud; writes only reconciliation rows (shadow
guarantees intact). 8 new tests, full suite green on odoo-trial.
2026-05-27 14:34:23 -04:00
..

Fusion Centralized Billing (fusion_centralize_billing)

Centralized billing engine that makes this Odoo 19 Enterprise instance the single billing brain for every NexaSystems service — NexaCloud hosting, NexaDesk chat, NexaMaps API, custom apps, and memberships. It replaces Lago and absorbs NexaCloud's home-grown Stripe billing into one customer ledger and one accounting system.

Design spec: docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md

Status: Core engine (sub-project #1) and the NexaCloud importer (sub-project #2a) are implemented and tested on odoo-trial Enterprise. 2b (usage wiring), 2c (control loop), and 2d (reconciliation) are pending.

Why this module is small

We build only the metering + integration layer. Everything financial — recurring invoicing, HST tax, proration, dunning, customer portal, credit notes, Stripe — is native Odoo Enterprise (sale_subscription, account_accountant, payment_stripe), already installed and running.

Design decisions (locked)

  1. Odoo fully replaces Lago (we build the metered-billing engine; Lago is decommissioned last).
  2. One unified res.partner per client; separate invoice per service.
  3. Apps drive, Odoo is the billing system of record — apps call the inbound API (as they call Lago today); Odoo bills and webhooks back.
  4. Odoo owns the billing catalog; apps own feature entitlements (shared plan_code).
  5. Pilot = NexaCloud, phased dual-run cutover.
  6. Aggregate-push usage ingestion (periodic counters, not a raw-event firehose).

Models (fusion.billing.*)

Model Purpose
fusion.billing.service One source app; bearer API key (hashed) + webhook config.
fusion.billing.account.link External account id → one res.partner (identity resolution).
fusion.billing.metric Billable metric + aggregation (sum/max/last/unique).
fusion.billing.charge Plan + metric → included quota + overage pricing.
fusion.billing.usage Aggregated per-period usage rollups (idempotent).
fusion.billing.webhook Outbound lifecycle event queue (HMAC + retry).
fusion.billing.reconciliation Dual-run Odoo-vs-app delta during cutover.

Odoo 19 note (verified): a subscription is a sale.order with is_subscription=True (plan_idsale.subscription.plan). There is no sale.subscription model. fusion.billing.usage.subscription_id therefore points at sale.order.

Inbound API

Lago-shaped REST under /api/billing/v1/*, bearer auth. Endpoints mirror NexaDesk's existing lago-client.ts so migration is a thin client swap. /health works today; the rest return 501 until implemented.

Relationship to fusion_api

fusion_api manages outbound provider keys (OpenAI, Maps, Twilio) + cost tracking — i.e. COGS. This module tracks customer revenue. Complementary: feed fusion_api cost into margin reporting; reuse its daily-rollup aggregation pattern.

Dependencies

account_accountant, sale_subscription, sale_management, payment_stripe.

Running the NexaCloud import (2a)

Exposed as Fusion Billing → Import from NexaCloud (a wizard). It runs entirely read-only against NexaCloud, and everything it creates in Odoo is shadow-safe (draft subscriptions, no payment token, charges with NULL plan_id) so it cannot charge or post during the dual-run.

1. Create a least-privilege read-only role in the NexaCloud Postgres (LXC 201):

CREATE ROLE odoo_billing_ro WITH LOGIN PASSWORD '<choose-a-strong-password>';
GRANT CONNECT ON DATABASE nexacloud TO odoo_billing_ro;
GRANT USAGE ON SCHEMA public TO odoo_billing_ro;
GRANT SELECT ON users, plans, subscriptions, deployments TO odoo_billing_ro;

2. Point Odoo at it via the system parameter (Settings → Technical → System Parameters, or odoo-shell). psycopg2 wants a libpq DSN — i.e. NexaCloud's SQLAlchemy URL without +asyncpg:

key:   fusion_billing.nexacloud_dsn
value: postgresql://odoo_billing_ro:<password>@<lxc201-host>:5432/nexacloud

(Odoo on nexa / VM 315 must have a network route to the LXC 201 Postgres port.)

3. Validate → dry-run → run for real:

  • Test Connection — confirms reachability + schema and reports row counts; imports nothing.
  • Run Import with Dry run ticked — computes the whole import inside a rolled-back savepoint and reports created / updated / skipped / failed counts; writes nothing. A red/amber banner flags any failures — investigate them before proceeding.
  • Untick Dry run and Run Import to persist the shadow copy. Re-running is safe and idempotent (upserts, never duplicates).

Local dev

docker exec odoo-nexa-app odoo -d nexamain -u fusion_centralize_billing --stop-after-init
# tests (once added):
docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing -u fusion_centralize_billing --stop-after-init

Canadian English, CAD, HST via account.tax. New fields on native models use the x_fc_* prefix.