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
mainatd770c0c3): 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)
- Per-deployment granularity. NexaCloud's own
subscriptionstable carriesdeployment_id+plan_id, so the natural mapping is one Odoo subscriptionsale.orderper deployment. (Confirms spec §15 Q2.) - 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.)
- CPU metric standardized to
cpu_seconds. The NexaCloud plan quota (plans.cpu_seconds_quota) is already in seconds, so it maps tocharge.included_quotawith no conversion. NexaCloud's CPU rate ($0.0075/core-hour) maps toprice_per_unit = 0.0075,unit_batch = 3600(one core-hour = 3600 cpu-seconds). - 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.)
- Importer = Odoo-side read-only reader (Approach A). An Odoo wizard connects
read-only to the
nexacloudPostgres, reads its tables, and writes only into Odoo via the existing model methods. No NexaCloud code is touched. - 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.extracarries billing address fields →res.partner(street,city,country_idresolved from the ISO/name,vatfromtax_id).- Stash
user.stripe_customer_idonres.partner.x_fc_stripe_customer_idso 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=Trueis 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_idis left NULL on purpose (see §6) — the hourly auto-rating cron skips charges with noplan_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 Yearlysale.subscription.planpersubscription.billing_cycle.order_line= one line: the plan's subscription product, qty 1,price_unitset explicitly toplan.price_monthlyorplan.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-onlypsycopg2connection using a DSN fromir.config_parameter(fusion_billing.nexacloud_dsn), runsSELECTs, 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': [...]}. Withdry_run=Trueit 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:
- 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. - 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.
- Charges are imported with
plan_id = NULL. The hourly_cron_rate_open_periodsskips charges without aplan_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 — nocr.commit()in tests). One malformed row (missing email, unknown plan, bad country) is recorded infailedwith its reason and skipped; the batch continues. - Rows that reference an unresolved parent (subscription whose user/plan failed) are
skippedwith a reason, not failed. _read_nexacloud_rows()raises a clearUserErrorif 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 viabash scripts/fcb_test_on_trial.sh) with hand-built fixture dicts:- partners + links created; re-run updates, does not duplicate (idempotency).
- catalog:
cpu_secondsmetric, product, and achargewithincluded_quota= quota,unit_batch=3600,price_per_unit=0.0075,plan_idNULL. - subscription: one draft
sale.orderper deployment,is_subscription=True,price_unit= the cycle's NexaCloud price,x_fc_shadow=True, no confirm. - shadow safety: imported subscription is
draft/not3_progress; noaccount.moveis created; partner has no payment token. - malformed rows land in
failed/skippedwithout aborting the batch. dry_run=Truewrites nothing (counts only).
- The
psycopg2read path is verified manually against the realnexacloudDB 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
/usageAPI resolves a subscription by its Odoo integer id (int(subscription_external_id)), but imported shadow subs are keyed by NexaCloud's UUID inx_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 byx_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.orderwithplan_id+ arecurring_invoice=Trueproduct line reportsis_subscription=True(sofusion.billing.usage.subscription_id's domain accepts it). product.template.recurring_invoiceis the correct field name in this build.sale.subscription.planfieldsbilling_period_value/billing_period_unit(used by the core tests) are the right find-or-create keys.res.partnercountry resolution field (country_id) andvatfortax_id.
12. Success criteria
- Running
_import_rows(fixture)produces, per the mapping in §4, partners+links, acpu_seconds-based charge catalog (plan_idNULL), and one draft shadow subscription per deployment with the correct flatprice_unit— and re-running it changes nothing (pure idempotency). - No
account.moveand 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 baresale.subscriptionmodel references.