167 lines
10 KiB
Markdown
167 lines
10 KiB
Markdown
# 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 #9–14 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`
|
||
(~1–2 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.
|