diff --git a/docs/superpowers/specs/2026-05-27-nexacloud-billing-importer-design.md b/docs/superpowers/specs/2026-05-27-nexacloud-billing-importer-design.md new file mode 100644 index 00000000..8eb222d0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-nexacloud-billing-importer-design.md @@ -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.