10 KiB
fusion_centralize_billing — Session Handoff (2026-05-27)
Resume point for the centralized-billing initiative. Read this first, then continue from "Decision pending" below.
Where we are
-
Sub-project #1 (core billing engine): DONE and on
main(tipd770c0c3, pushed to GitHub + Gitea).- 11/11 plan tasks, TDD, Opus code-reviewed; all Critical/High bugs fixed
(cross-billing cron → match by
plan_id;/usageauthz vs IDOR; input validation → 4xx not 500; correct billing-period window; idempotency scoped to(sub, metric, key); webhook sign-exact-bytes + event-id + SSRF guard). - 39 tests green on Odoo 19 Enterprise.
- Note: the 14 billing commits were rebased off the old login-audit/helpdesk stack and
landed cleanly on
main.
- 11/11 plan tasks, TDD, Opus code-reviewed; all Critical/High bugs fixed
(cross-billing cron → match by
-
fusion_login_audit: also landed onmain(2026-05-27). Its 19 commits were rebased ontomainand thefeat/fusion-login-auditbranch was deleted. This also restored Odoo-19 rules #9–14 inCLAUDE.md, which had gone missing onmainwhen billing landed alone (they were authored alongside login_audit and never existed on the old base).- A concurrent
feat/helpdesk-customer-followupsession still carries pre-landing copies of the billing + login_audit commits; when it merges, replay its helpdesk-only commits ontomain.
- A concurrent
-
Reference docs (on
main):- Spec:
docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md - Core plan:
docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md
- Spec:
Next: sub-project #2 — NexaCloud adapter + dual-run reconciliation
Per spec §12, each sub-project is its own spec → plan → build cycle. #2 decomposes into four chunks (dependency order):
| Chunk | What | Risk |
|---|---|---|
| 2a — Mapping + importer | Read nexacloud DB → create res.partner + account.link, product.template + subscription plans, one subscription sale.order per deployment |
Low — read-only on NexaCloud, writes only into Odoo |
| 2b — Usage metering wiring | NexaCloud usage_metering.py pushes CPU-seconds → Odoo /usage; verify aggregation → draft invoice w/ quota + overage + HST |
Edits NexaCloud code |
| 2c — Control loop | NexaCloud consumes Odoo's outbound webhooks (invoice.payment_failed → suspend via existing network_isolation/throttle_checker; subscription.terminated → deprovision) |
Edits NexaCloud code |
| 2d — Dual-run reconciliation | fusion.billing.reconciliation diffs Odoo-computed vs NexaCloud-actual per customer/period for ≥ 1 cycle before any flip |
Safety gate before flipping real billing |
The core engine already built the receiving side (/usage, webhook engine, charge math).
#2 is about connecting NexaCloud to it and proving the numbers match before flipping.
Decision pending (resume here)
We were in the superpowers:brainstorming flow for #2 and stopped at: which slice to
start with?
- (recommended) 2a — Mapping + importer — lowest risk, foundation for everything else.
- 2d — Reconciliation first (front-load the trust mechanism).
- Full #2 design as one spec, then one plan.
- Just write the #2 plan, no code this session.
Open questions to resolve before building #2
- Spec §15 Q2 — NexaCloud billing granularity: confirm one subscription per deployment (spec leans this way) vs one subscription per customer with deployment line items.
- Access / environments needed:
- Read access to the
nexacloudDB schema (LXC 102 / its Postgres on LXC 201) to design the importer mapping. - A NexaCloud staging or safe path for 2b/2c (they edit live NexaCloud code).
- Test target for the Odoo side stays the odoo-trial Enterprise sandbox.
- Read access to the
- Resolved already: Stripe is one account (
acct_1ShlA9IkwUB1dVox) for everything — no account migration (spec §11 / §15 Q1). Branch strategy — land onmain, branch new work offmain.
How to run / test
- Billing tests:
bash scripts/fcb_test_on_trial.shfrom repo root → pass =FCB_EXIT=0(~1–2 min). Syncs the module to the odoo-trial Enterprise sandbox (Proxmox VM 316, dbtrial) and runs--test-enable. Local dev Odoo is Community and cannot install this module.
Branch hygiene (lesson from this session)
Cut each new feature branch from main, and land it before starting the next. For any
cross-branch git surgery, use a throwaway git worktree — never switch the shared
working dir's branch, because a concurrent session may be working on it.
UPDATE — sub-project #2 complete (2026-05-27, later session)
All four chunks of #2 are now built. The brainstorm "which slice" question resolved to 2a-first; everything else followed.
Done + on main in Odoo-Modules (fully tested on odoo-trial, suite FCB_EXIT=0):
- 2a — importer (
fusion.billing.import.wizard): read-onlypsycopg2reader split from pure-Odoo writes; users→partners+links, plans→cpu_secondscharge catalog (plan_idNULL), deployments→one draft shadowsale.ordereach with the flat price. Shadow-safe by construction (draft + no token + NULLplan_id). Idempotent, dry-run, Test-Connection guard, README runbook. - 2d — reconciliation (
fusion.billing.reconciliation):_compute_reconciliation+_reconcile_rows(Odoo flat+overage vs NexaCloud actual, status match/delta), reader for NexaCloud usage+invoice actuals, "Run Reconciliation" button. Upsert key is(service, external_subscription_id, period)— per subscription, so a customer with two deployments doesn't collide. - /usage enabler:
_api_record_usageresolves a subscription by the source app's own id (x_fc_nexacloud_subscription_id) so NexaCloud can push against shadow subs. - Core-engine bug fixed in passing:
charge.price_per_unitis nowFloat(16,6)and_compute_billablekeeps 6-dp precision (wasMonetary/cent-rounded → would under-bill sub-cent rates and drift from NexaCloud's 4-dp amounts).
Code-complete in Nexa-Cloud (feature-flagged, NOT deployed, NOT integration-tested):
- 2b — usage push:
services/odoo_billing_client.py+ a hook inusage_metering.pyposting cpu-seconds to Odoo/usage. 2c — control loop:routers/odoo_billing.py(POST /api/v1/billing/webhooks/central, HMAC-verified) +services/odoo_billing_integration.py(suspend/restore/deprovision). All INERT unlessODOO_BILLING_ENABLED. Implemented as NEW modules + edits to clean files only — NexaCloudmainhad concurrent Cursor uncommitted WIP (routers/billing.py,scheduler.py,stripe_service.py,models/billing.py, …) which was deliberately not touched. Commits:94542ec+956abb2(only my files staged).
Deployment status (2026-05-27):
- odoo-nexa (production
nexamain): DEPLOYED —fusion_centralize_billing(core + 2a- 2d) fresh-installed (#1 had never actually been deployed here;
DIR_ABSENTbefore).ir_module_module.state = installed,odoo-nexa-apphealthy. INERT: nonexacloud_dsn, all chargesplan_idNULL (rating cron no-op), no webhooks queued (dispatch cron no-op), inbound API 401s with no key configured. Synced to/opt/odoo/custom-addons+-ivia the restart-safe recipe.
- 2d) fresh-installed (#1 had never actually been deployed here;
- NexaCloud (prod,
vps.nexasystems.ca/ 192.168.1.250): DEPLOYED — INERT. Did NOT use./deploy.sh(itrsync --deletes the working tree → would have shipped the concurrent uncommitted Cursor WIP (7 files) AND wiped the gitignored prod.envfiles). Instead deployed surgically: rsync of ONLY my 6 committed billing files (no--delete;.env+ Cursor's files untouched),docker compose build backend, boot-tested in a throwaway container (run --rm --no-deps backend python -c "import app.main"→ BOOT_OK) before swapping, thenup -d backend.nexacloud-apihealthy,/healthOK. Feature OFF:ODOO_BILLING_ENABLEDunset →/billing/webhooks/centralreturns 404 and no usage is pushed. Activate later by settingODOO_BILLING_*in/opt/nexacloud/.env(+ compose env passthrough) once the Odoo side is wired. NOTE: Cursor's 7-file WIP remains uncommitted locally and was never deployed — when Cursor finishes, a normal./deploy.shwill ship it (and re-sync.env).
Dual-run stand-up results (2026-05-27) — STOPPED here for review, NOT flipped:
- Read-only role
odoo_billing_rocreated on nexacloud Postgres (192.168.1.50); DSN set inir.config_parameterfusion_billing.nexacloud_dsnon nexamain. Test Connection OK (read 7 users / 232 plans / 87 subscriptions). - Shadow import committed on nexamain: 7 partners, 232 plan catalogs, 87 draft shadow
subscriptions; 0 skipped, 0 failed. (NOTE: importer takes ALL plans/subs regardless of
active status → ~464 NC-* products now in the prod ERP catalog. Consider filtering to
is_activeplans / active subscriptions, or prune the shadow records — all reversible.) - Reconciliation pass: 9 (sub,period) rows had real billing activity → 2 match, 7
delta, 0 failed. The 7 deltas, MUST resolve before flipping:
- One-off / non-subscription invoices (3 rows: $877.99, $872.66, $32.20) — nexacloud invoices with NULL subscription_id (fees/manual/credits); not modeled per-subscription.
- List-price ≠ actual-invoiced (4 rows: Odoo $200/$50 vs actual ~$9.1x) — likely proration or NexaCloud invoicing ≠ plan list price.
- 2d bug surfaced (analysis-only, not safety):
_reconcile_rowswith an emptysubscription_external_idmatches NULL-field orders instead of skipping → spurious delta rows for the one-off invoices. Addif not sub_ext: skip.
Remaining before go-live (gated on infra / ops you do):
- Grant the read-only DSN (
fusion_billing.nexacloud_dsn) — see the module README — then Test Connection → dry-run import → review → real import. - Run a dual-run cycle (Run Reconciliation), confirm all rows
match. - 2c needs the Odoo side to actually EMIT
invoice.payment_failed/payment_succeeded/subscription.terminatedwebhooks withdeployment_idin the payload — that emission isn't wired yet (it belongs to the live billing flow). The NexaCloud receiver is built to that contract; confirm the payload shape when wiring it. - Integration-test + deploy the NexaCloud changes (no test harness in that repo).
- The flip: set
charge.plan_id, attach Stripe tokens, confirm the shadow subs.
Specs/plans: specs/2026-05-27-nexacloud-billing-importer-design.md,
specs/2026-05-27-nexacloud-reconciliation-design.md, and the matching plans.