Files
Odoo-Modules/docs/superpowers/specs/2026-05-27-nexacloud-billing-importer-design.md

13 KiB

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:

  • usersid (UUID), email, full_name, company, billing_email, billing_address/_city/_state/_postal_code/_country, tax_id, stripe_customer_id.
  • plansid (UUID), product_id, name, price_monthly, price_yearly, stripe_price_id, cpu_seconds_quota (BigInteger), is_active.
  • deploymentsid (UUID), user_id, product_id, plan_id, name, status, billing_cycle, next_due_date.
  • subscriptionsid (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)

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 SELECTs, 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.

Flip-day note (carry into 2b): the inbound /usage API resolves a subscription by its Odoo integer id (int(subscription_external_id)), but imported shadow subs are keyed by NexaCloud's UUID in x_fc_nexacloud_subscription_id. Before NexaCloud can push usage (2b), decide how it learns the Odoo id (return the mapping from the importer, or extend the usage API to also resolve by x_fc_nexacloud_subscription_id). Not a 2a bug (2a is read-only), but it must be resolved before the flip.

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.