Files
Odoo-Modules/docs/superpowers/2026-05-27-fusion-billing-session-handoff.md

167 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`** (tip `d770c0c3`, pushed to
GitHub + Gitea).
- 11/11 plan tasks, TDD, Opus code-reviewed; all Critical/High bugs fixed
(cross-billing cron → match by `plan_id`; `/usage` authz 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`.
- **`fusion_login_audit`: also landed on `main`** (2026-05-27). Its 19 commits were rebased
onto `main` and the `feat/fusion-login-audit` branch was deleted. This also restored
Odoo-19 rules #914 in `CLAUDE.md`, which had gone missing on `main` when billing landed
alone (they were authored alongside login_audit and never existed on the old base).
- A concurrent `feat/helpdesk-customer-followup` session still carries pre-landing copies
of the billing + login_audit commits; when it merges, replay its helpdesk-only commits
onto `main`.
- **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`
## 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 `nexacloud` DB 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.
- **Resolved already:** Stripe is one account (`acct_1ShlA9IkwUB1dVox`) for everything — no
account migration (spec §11 / §15 Q1). Branch strategy — land on `main`, branch new work
off `main`.
## How to run / test
- **Billing tests:** `bash scripts/fcb_test_on_trial.sh` from repo root → pass = `FCB_EXIT=0`
(~12 min). Syncs the module to the odoo-trial Enterprise sandbox (Proxmox VM 316, db
`trial`) 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-only `psycopg2` reader split
from pure-Odoo writes; users→partners+links, plans→`cpu_seconds` charge catalog
(`plan_id` NULL), deployments→one **draft shadow** `sale.order` each with the flat price.
Shadow-safe by construction (draft + no token + NULL `plan_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_usage` resolves 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_unit` is now `Float(16,6)` and
`_compute_billable` keeps 6-dp precision (was `Monetary`/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 in `usage_metering.py`
posting 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 unless
`ODOO_BILLING_ENABLED`. Implemented as NEW modules + edits to clean files only —
NexaCloud `main` had 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_ABSENT` before).
`ir_module_module.state = installed`, `odoo-nexa-app` healthy. **INERT**: no
`nexacloud_dsn`, all charges `plan_id` NULL (rating cron no-op), no webhooks queued
(dispatch cron no-op), inbound API 401s with no key configured. Synced to
`/opt/odoo/custom-addons` + `-i` via the restart-safe recipe.
- **NexaCloud (prod, `vps.nexasystems.ca` / 192.168.1.250): DEPLOYED — INERT.** Did NOT
use `./deploy.sh` (it `rsync --delete`s the working tree → would have shipped the
concurrent **uncommitted Cursor WIP** (7 files) AND wiped the gitignored prod `.env`
files). 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, then `up -d backend`. `nexacloud-api` healthy,
`/health` OK. Feature OFF: `ODOO_BILLING_ENABLED` unset → `/billing/webhooks/central`
returns 404 and no usage is pushed. Activate later by setting `ODOO_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.sh` will 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_ro` created on nexacloud Postgres (192.168.1.50); DSN set in
`ir.config_parameter` `fusion_billing.nexacloud_dsn` on 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_active` plans / 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:
1. **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.
2. **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_rows` with an empty
`subscription_external_id` matches NULL-field orders instead of skipping → spurious
delta rows for the one-off invoices. Add `if not sub_ext: skip`.
**Remaining before go-live (gated on infra / ops you do):**
1. Grant the read-only DSN (`fusion_billing.nexacloud_dsn`) — see the module README — then
Test Connection → dry-run import → review → real import.
2. Run a dual-run cycle (Run Reconciliation), confirm all rows `match`.
3. **2c needs the Odoo side to actually EMIT** `invoice.payment_failed` /
`payment_succeeded` / `subscription.terminated` webhooks with `deployment_id` in 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.
4. Integration-test + deploy the NexaCloud changes (no test harness in that repo).
5. 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.