docs(billing): design spec for sub-project #2a NexaCloud→Odoo importer
One-time, re-runnable, read-only importer that backfills NexaCloud customers/plans/deployments into Odoo as a shadow copy for dual-run reconciliation. Locks the brainstorming decisions: per-deployment granularity, flat+overage billing, cpu_seconds metric, CPU-only v1, Odoo-side psycopg2 reader, and shadow-safety by construction (draft subs + no payment token + charges with NULL plan_id).
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
# Sub-project #2a — NexaCloud → Odoo Billing Importer (Design)
|
||||
|
||||
- **Date:** 2026-05-27
|
||||
- **Status:** Design approved (brainstorming session) — implementation in progress
|
||||
- **Module:** `fusion_centralize_billing` (Odoo 19 Enterprise, host odoo-nexa / tested on odoo-trial)
|
||||
- **Parent:** Sub-project #2 (NexaCloud adapter + dual-run reconciliation). This spec covers **chunk 2a only** — the read-only importer/backfill. 2b (usage wiring), 2c (control loop), 2d (reconciliation) are separate specs.
|
||||
- **Depends on:** the core engine (sub-project #1, on `main` at `d770c0c3`): service registry, `_resolve_or_create_partner`, `fusion.billing.charge._compute_billable`, `fusion.billing.usage`, the inbound API, the webhook engine.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Backfill the **existing** NexaCloud customers, plans, and deployments into Odoo so the
|
||||
central billing engine has a complete shadow copy to run dual-run reconciliation (2d)
|
||||
against. The importer is a **one-time, re-runnable** migration — *not* a continuous sync.
|
||||
New NexaCloud signups after the cutover already flow through the live inbound API built in
|
||||
sub-project #1.
|
||||
|
||||
The importer must be **safe by construction**: while NexaCloud is still the live biller,
|
||||
nothing the importer creates in Odoo may charge, post, or email a customer.
|
||||
|
||||
## 2. Decisions locked in brainstorming (2026-05-27)
|
||||
|
||||
1. **Per-deployment granularity.** NexaCloud's own `subscriptions` table carries
|
||||
`deployment_id` + `plan_id`, so the natural mapping is **one Odoo subscription
|
||||
`sale.order` per deployment**. (Confirms spec §15 Q2.)
|
||||
2. **Billing model = flat plan price + metered overage.** Customers pay a fixed
|
||||
monthly/yearly plan price PLUS per-unit charges for usage above the plan's quota.
|
||||
(Confirms the original §6 quota+overage assumption.)
|
||||
3. **CPU metric standardized to `cpu_seconds`.** The NexaCloud plan quota
|
||||
(`plans.cpu_seconds_quota`) is already in seconds, so it maps to `charge.included_quota`
|
||||
with no conversion. NexaCloud's CPU rate ($0.0075/core-hour) maps to
|
||||
`price_per_unit = 0.0075`, `unit_batch = 3600` (one core-hour = 3600 cpu-seconds).
|
||||
4. **CPU is the only metered-overage metric in v1.** It is the only resource with a plan
|
||||
quota. RAM / disk / bandwidth are treated as bundled in the flat plan price for now,
|
||||
addable later as more metrics if NexaCloud actually bills them as overage. (YAGNI.)
|
||||
5. **Importer = Odoo-side read-only reader** (Approach A). An Odoo wizard connects
|
||||
read-only to the `nexacloud` Postgres, reads its tables, and writes only into Odoo via
|
||||
the existing model methods. No NexaCloud code is touched.
|
||||
6. **Idempotent / re-runnable.** Every created entity is upserted on a stable key, so the
|
||||
importer can run each cycle during the dual-run and update rather than duplicate.
|
||||
|
||||
## 3. Source data (NexaCloud, read-only)
|
||||
|
||||
Confirmed by reading `/Users/gurpreet/Github/Nexa-Cloud/backend/app/models`. FastAPI +
|
||||
async SQLAlchemy on Postgres. Relevant tables/columns:
|
||||
|
||||
- **`users`** — `id` (UUID), `email`, `full_name`, `company`, `billing_email`,
|
||||
`billing_address`/`_city`/`_state`/`_postal_code`/`_country`, `tax_id`,
|
||||
`stripe_customer_id`.
|
||||
- **`plans`** — `id` (UUID), `product_id`, `name`, `price_monthly`, `price_yearly`,
|
||||
`stripe_price_id`, `cpu_seconds_quota` (BigInteger), `is_active`.
|
||||
- **`deployments`** — `id` (UUID), `user_id`, `product_id`, `plan_id`, `name`, `status`,
|
||||
`billing_cycle`, `next_due_date`.
|
||||
- **`subscriptions`** — `id` (UUID), `user_id`, `deployment_id`, `plan_id`, `status`
|
||||
(active/cancelled/past_due/trialing/paused), `billing_cycle` (monthly/yearly),
|
||||
`current_period_start`, `current_period_end`, `stripe_subscription_id`.
|
||||
|
||||
(The `usage_records`, `invoices`, `addons` tables are out of scope for 2a — usage wiring
|
||||
is 2b; reconciliation against NexaCloud invoice/usage totals is 2d.)
|
||||
|
||||
## 4. Data mapping
|
||||
|
||||
| NexaCloud (read) | Odoo (upsert) | Idempotency key |
|
||||
|---|---|---|
|
||||
| `users` | `res.partner` + `fusion.billing.account.link` (service=`nexacloud`, external_id=`user.id`) | `account.link (service_id, external_id)` (existing unique constraint) |
|
||||
| `plans` | one subscription `product.template` (flat price) + one CPU-overage `product.product` + one `fusion.billing.charge` | `charge.plan_code = plan.id` (UUID string) |
|
||||
| `subscriptions`/`deployments` | one **draft** `sale.order(is_subscription)` per deployment | `sale.order.x_fc_nexacloud_subscription_id` |
|
||||
| (constant) | `fusion.billing.metric` `cpu_seconds` | `metric.code` (existing unique) |
|
||||
| (constant) | `sale.subscription.plan` Monthly + Yearly recurrences | `(billing_period_value, billing_period_unit)` |
|
||||
|
||||
### 4.1 Identity (`users` → partner + link)
|
||||
|
||||
Reuse `account_link._resolve_or_create_partner(service, external_id, name, email, extra)`.
|
||||
- `external_id` = `str(user.id)`, `email` = `user.billing_email or user.email`,
|
||||
`name` = `user.full_name or user.company or email`.
|
||||
- `extra` carries billing address fields → `res.partner` (`street`, `city`, `country_id`
|
||||
resolved from the ISO/name, `vat` from `tax_id`).
|
||||
- Stash `user.stripe_customer_id` on `res.partner.x_fc_stripe_customer_id` so the eventual
|
||||
flip (not 2a) can reuse the existing Stripe customer instead of creating a new one.
|
||||
|
||||
### 4.2 Catalog (`plans` → product + charge)
|
||||
|
||||
For each active NexaCloud plan:
|
||||
- **Subscription product** (`product.template`, `type='service'`, `recurring_invoice=True`)
|
||||
named after the plan. `recurring_invoice=True` is what makes Odoo treat an order using
|
||||
it as a subscription (verified pattern from the core engine's `_api_create_subscription`).
|
||||
- **CPU-overage product** (`product.product`, `type='service'`) — the product the rating
|
||||
math attaches the overage amount to (`charge.product_id`).
|
||||
- **`fusion.billing.charge`**: `plan_code=str(plan.id)`, `metric_id=cpu_seconds`,
|
||||
`product_id=`overage product, `included_quota=plan.cpu_seconds_quota`,
|
||||
`price_per_unit=0.0075`, `unit_batch=3600`, `charge_model='standard'`, CAD.
|
||||
**`plan_id` is left NULL on purpose** (see §6) — the hourly auto-rating cron skips
|
||||
charges with no `plan_id`, so importing charges never auto-mutates shadow subscriptions.
|
||||
|
||||
### 4.3 Subscription (`deployment` → draft shadow sale.order)
|
||||
|
||||
For each deployment that has a NexaCloud subscription:
|
||||
- `partner_id` = the mapped partner.
|
||||
- `plan_id` = the Monthly or Yearly `sale.subscription.plan` per `subscription.billing_cycle`.
|
||||
- `order_line` = one line: the plan's subscription product, qty 1, **`price_unit` set
|
||||
explicitly** to `plan.price_monthly` or `plan.price_yearly` (matching the cycle). Setting
|
||||
the price explicitly makes Odoo's computed amount match NexaCloud's by construction —
|
||||
it does not depend on Odoo subscription-pricing internals or a pricelist.
|
||||
- `x_fc_nexacloud_subscription_id` = `str(subscription.id)` (upsert key),
|
||||
`x_fc_nexacloud_deployment_id` = `str(deployment.id)`,
|
||||
`x_fc_billing_service_id` = the nexacloud service, `x_fc_shadow = True`.
|
||||
- **Left in draft** (`action_confirm()` is NOT called). No payment token is attached.
|
||||
|
||||
## 5. Architecture / mechanism
|
||||
|
||||
A new transient model **`fusion.billing.import.wizard`** with one button, but the logic
|
||||
lives in two model methods so it is unit-testable headless (the core-engine pattern —
|
||||
logic in model methods, thin UI):
|
||||
|
||||
- **`_read_nexacloud_rows()`** — opens a **read-only `psycopg2`** connection using a DSN
|
||||
from `ir.config_parameter` (`fusion_billing.nexacloud_dsn`), runs `SELECT`s, and returns
|
||||
a plain dict: `{'users': [...], 'plans': [...], 'subscriptions': [...]}` (rows as dicts).
|
||||
This is the *only* code that touches NexaCloud, and it only reads.
|
||||
- **`_import_rows(data, dry_run=False)`** — pure Odoo writes. Consumes the dict, upserts in
|
||||
FK order (metric+recurrences → partners → catalog → subscriptions), returns a summary
|
||||
`{'created': {...}, 'updated': {...}, 'skipped': [...], 'failed': [...]}`. With
|
||||
`dry_run=True` it computes the summary inside a rolled-back savepoint and writes nothing.
|
||||
|
||||
`action_run_import()` on the wizard wires them: `self._import_rows(self._read_nexacloud_rows(), dry_run=self.dry_run)`.
|
||||
|
||||
## 6. Shadow-mode safety (the critical property)
|
||||
|
||||
While NexaCloud is the live biller, the importer must not produce any customer-visible
|
||||
billing in Odoo. Three independent guarantees, any one of which is sufficient:
|
||||
|
||||
1. **Subscriptions are imported in `draft`.** Odoo's native recurring-invoice cron only
|
||||
invoices confirmed (`3_progress`) subscriptions, so draft imports are never auto-invoiced,
|
||||
posted, or emailed.
|
||||
2. **No payment token is imported.** Even a posted invoice could not be auto-charged,
|
||||
because Odoo has no saved Stripe payment method for the partner. Charging is physically
|
||||
impossible.
|
||||
3. **Charges are imported with `plan_id = NULL`.** The hourly `_cron_rate_open_periods`
|
||||
skips charges without a `plan_id`, so importing the catalog never mutates any order line.
|
||||
|
||||
`x_fc_shadow=True` marks every imported subscription for later identification. The flip
|
||||
(out of scope here) is: set `charge.plan_id`, attach payment tokens, `action_confirm()`.
|
||||
|
||||
## 7. Error handling
|
||||
|
||||
- **Per-row `savepoint`** (`with self.env.cr.savepoint():`) around each entity write
|
||||
(CLAUDE rule #14 — no `cr.commit()` in tests). One malformed row (missing email, unknown
|
||||
plan, bad country) is recorded in `failed` with its reason and skipped; the batch
|
||||
continues.
|
||||
- Rows that reference an unresolved parent (subscription whose user/plan failed) are
|
||||
`skipped` with a reason, not failed.
|
||||
- `_read_nexacloud_rows()` raises a clear `UserError` if the DSN config param is missing or
|
||||
the connection fails — the wizard surfaces it; nothing is half-written (read happens
|
||||
before any write).
|
||||
|
||||
## 8. Testing
|
||||
|
||||
Split mirrors §5 so the Odoo logic is fully testable without a foreign DB:
|
||||
- **`_import_rows(data)` unit tests** (`TransactionCase`, run on odoo-trial Enterprise via
|
||||
`bash scripts/fcb_test_on_trial.sh`) with hand-built fixture dicts:
|
||||
- partners + links created; re-run updates, does not duplicate (idempotency).
|
||||
- catalog: `cpu_seconds` metric, product, and a `charge` with `included_quota` = quota,
|
||||
`unit_batch=3600`, `price_per_unit=0.0075`, **`plan_id` NULL**.
|
||||
- subscription: one **draft** `sale.order` per deployment, `is_subscription=True`,
|
||||
`price_unit` = the cycle's NexaCloud price, `x_fc_shadow=True`, no confirm.
|
||||
- shadow safety: imported subscription is `draft`/not `3_progress`; no `account.move`
|
||||
is created; partner has no payment token.
|
||||
- malformed rows land in `failed`/`skipped` without aborting the batch.
|
||||
- `dry_run=True` writes nothing (counts only).
|
||||
- The `psycopg2` read path is verified manually against the real `nexacloud` DB once
|
||||
access is granted (cannot be unit-tested against a foreign DB).
|
||||
|
||||
## 9. Prerequisite (flagged, not blocking the build)
|
||||
|
||||
Odoo on nexa (VM 315) needs network reachability + a **read-only credential** to the
|
||||
`nexacloud` Postgres (LXC 201), stored as `ir.config_parameter` `fusion_billing.nexacloud_dsn`.
|
||||
The build and all unit tests proceed with fixtures; only the live import run is blocked
|
||||
until this is granted.
|
||||
|
||||
## 10. Out of scope (YAGNI / later chunks)
|
||||
|
||||
- RAM / disk / bandwidth overage metrics (only if NexaCloud bills them — add as metrics).
|
||||
- The **flip** to live billing (confirm subs, attach tokens, set `charge.plan_id`).
|
||||
- Usage metering wiring (2b), control-loop webhooks (2c), reconciliation compute (2d).
|
||||
- Importing historical NexaCloud invoices / `usage_records` (2d reads NexaCloud actuals).
|
||||
- Add-ons (`deployment_addons`) as recurring lines — revisit if material.
|
||||
|
||||
## 11. Verify at implementation (do NOT code from memory — CLAUDE rule #1)
|
||||
|
||||
Confirm on odoo-trial Enterprise before relying on them:
|
||||
- A **draft** `sale.order` with `plan_id` + a `recurring_invoice=True` product line reports
|
||||
`is_subscription=True` (so `fusion.billing.usage.subscription_id`'s domain accepts it).
|
||||
- `product.template.recurring_invoice` is the correct field name in this build.
|
||||
- `sale.subscription.plan` fields `billing_period_value` / `billing_period_unit` (used by
|
||||
the core tests) are the right find-or-create keys.
|
||||
- `res.partner` country resolution field (`country_id`) and `vat` for `tax_id`.
|
||||
|
||||
## 12. Success criteria
|
||||
|
||||
- Running `_import_rows(fixture)` produces, per the mapping in §4, partners+links, a
|
||||
`cpu_seconds`-based charge catalog (`plan_id` NULL), and one **draft** shadow subscription
|
||||
per deployment with the correct flat `price_unit` — and re-running it changes nothing
|
||||
(pure idempotency).
|
||||
- No `account.move` and no payment token exist for any imported partner after an import
|
||||
(shadow safety, asserted in tests).
|
||||
- Full suite green on odoo-trial (`FCB_EXIT=0`); no `_sql_constraints`, no bare
|
||||
`sale.subscription` model references.
|
||||
Reference in New Issue
Block a user