# NexaCloud → Odoo Billing Importer (Sub-project #2a) — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a one-time, re-runnable, read-only importer that backfills NexaCloud customers/plans/deployments into Odoo as a shadow copy (drafts, no charge) for dual-run reconciliation. **Architecture:** A `fusion.billing.import.wizard` transient model. `_read_nexacloud_rows()` opens a read-only `psycopg2` connection (DSN from `ir.config_parameter`) and returns plain row dicts — the only code touching NexaCloud. `_import_rows(data, dry_run)` is pure Odoo: it upserts the `nexacloud` service, a `cpu_seconds` metric, Monthly/Yearly recurrences, partners+links (reusing `_resolve_or_create_partner`), a per-plan catalog (product + CPU-overage product + `fusion.billing.charge` with `plan_id` left NULL), and one **draft** shadow `sale.order` per deployment with the flat price set explicitly on the line. Shadow-safety holds by construction: draft + no payment token + charge `plan_id` NULL. **Tech Stack:** Odoo 19 Enterprise (Python 3.12), `sale_subscription`, `account_accountant`, `payment_stripe`, `psycopg2`. Tests: `odoo.tests.common.TransactionCase` on odoo-trial. **Spec:** `docs/superpowers/specs/2026-05-27-nexacloud-billing-importer-design.md` --- ## Conventions for every task - **Never code Odoo internals from memory** (repo CLAUDE.md rule #1). The uncertain internals (`recurring_invoice`, `is_subscription` on a draft order, `sale.subscription.plan` fields, `price_unit` stickiness, `sale.subscription.plan` `billing_period_unit` values) are *verified by the tests themselves* on odoo-trial — when a test fails because an assumption is wrong, fix the source, do not weaken the assertion. - **Models, not UI:** all logic lives in `_import_rows` / `_do_import` / `_import_*` model methods; the wizard button only calls them. This keeps everything testable under `TransactionCase`. - **Money:** CAD, prices are `Float`/`Monetary`. CPU overage: `price_per_unit=0.0075`, `unit_batch=3600`. - **New fields on native models:** `x_fc_*` prefix. - **Registering tests:** append `from . import test_importer` to `tests/__init__.py` in the task that creates it; commit `__init__.py` alongside so the package always imports. ## Test environment Tests run on **odoo-trial** (Proxmox VM 316, Odoo 19 Enterprise, db `trial`) — local dev is Community and cannot install this module. One runner: ```bash bash scripts/fcb_test_on_trial.sh ``` - It re-syncs the module to the sandbox and runs `-u fusion_centralize_billing --test-enable --test-tags /fusion_centralize_billing`. - **Pass condition:** output contains `FCB_EXIT=0`. - The script runs the **whole** FCB suite (it cannot target one test); every "run the test" step below means "run the suite, ~1–2 min". - **Never** run `--test-enable` against production `nexamain`. ## File structure (this plan) ``` fusion_centralize_billing/ __init__.py # + from . import wizards models/ __init__.py # + from . import res_partner sale_order.py # + x_fc_* fields on the existing SaleOrder inherit res_partner.py # NEW: x_fc_stripe_customer_id wizards/ __init__.py # NEW import_wizard.py # NEW: the importer (read + import logic) views/ import_wizard_views.xml # NEW: wizard form + action + menu security/ ir.model.access.csv # + wizard ACL line __manifest__.py # + views file tests/ __init__.py # + from . import test_importer test_importer.py # NEW ``` --- ## Task 1: Scaffolding — x_fc fields, partner inherit, wizard skeleton, security, manifest **Files:** - Modify: `fusion_centralize_billing/models/sale_order.py` - Create: `fusion_centralize_billing/models/res_partner.py` - Modify: `fusion_centralize_billing/models/__init__.py` - Create: `fusion_centralize_billing/wizards/__init__.py` - Create: `fusion_centralize_billing/wizards/import_wizard.py` - Create: `fusion_centralize_billing/views/import_wizard_views.xml` - Modify: `fusion_centralize_billing/__init__.py` - Modify: `fusion_centralize_billing/security/ir.model.access.csv` - Modify: `fusion_centralize_billing/__manifest__.py` - [ ] **Step 1: Add `x_fc_*` fields to the existing `sale.order` inherit** In `models/sale_order.py`, add these fields to the `SaleOrder` class (keep `_fc_rate_usage`): ```python x_fc_nexacloud_subscription_id = fields.Char( index=True, copy=False, help="Source NexaCloud subscription id — the importer's idempotency key.") x_fc_nexacloud_deployment_id = fields.Char(index=True, copy=False) x_fc_billing_service_id = fields.Many2one( "fusion.billing.service", index=True, copy=False, ondelete="set null") x_fc_shadow = fields.Boolean( default=False, copy=False, help="Imported in shadow mode: Odoo computes but must not charge/post/email.") ``` - [ ] **Step 2: Create the `res.partner` inherit** `fusion_centralize_billing/models/res_partner.py`: ```python # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 from odoo import fields, models class ResPartner(models.Model): _inherit = "res.partner" x_fc_stripe_customer_id = fields.Char( index=True, copy=False, help="Existing Stripe customer id imported from a source app, reused at flip.") ``` Append to `models/__init__.py`: `from . import res_partner`. - [ ] **Step 3: Create the wizard skeleton** `fusion_centralize_billing/wizards/__init__.py`: ```python from . import import_wizard ``` `fusion_centralize_billing/wizards/import_wizard.py`: ```python # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 import json import logging from odoo import api, fields, models from odoo.exceptions import UserError _logger = logging.getLogger(__name__) NEXACLOUD_CODE = "nexacloud" CPU_METRIC_CODE = "cpu_seconds" CPU_RATE_PER_CORE_HOUR = 0.0075 # NexaCloud CPU rate, CAD per core-hour CPU_SECONDS_PER_CORE_HOUR = 3600.0 # one core-hour = 3600 cpu-seconds class FusionBillingImportWizard(models.TransientModel): _name = "fusion.billing.import.wizard" _description = "Fusion Billing — NexaCloud Importer" dry_run = fields.Boolean( default=True, help="Read and report what would be imported, without writing anything.") result_summary = fields.Text(readonly=True) def action_run_import(self): self.ensure_one() data = self._read_nexacloud_rows() summary = self._import_rows(data, dry_run=self.dry_run) self.result_summary = json.dumps(summary, indent=2, default=str) return { "type": "ir.actions.act_window", "res_model": self._name, "res_id": self.id, "view_mode": "form", "target": "new", } # ----- read side (the ONLY code that touches NexaCloud) ------------------ def _read_nexacloud_rows(self): """Open a READ-ONLY psycopg2 connection to the nexacloud Postgres (DSN in ir.config_parameter 'fusion_billing.nexacloud_dsn') and return rows as dicts. Raises UserError on a missing DSN or a failed connection.""" import psycopg2 import psycopg2.extras dsn = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.nexacloud_dsn") if not dsn: raise UserError( "NexaCloud DSN not configured. Set the 'fusion_billing.nexacloud_dsn' " "system parameter to a read-only Postgres connection string.") try: conn = psycopg2.connect(dsn) except Exception as e: # noqa: BLE001 - surface as a user error raise UserError("Could not connect to the NexaCloud database: %s" % e) try: conn.set_session(readonly=True) cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) data = {} cur.execute( "SELECT id, email, full_name, company, billing_email, billing_address, " "billing_city, billing_state, billing_postal_code, billing_country, " "tax_id, stripe_customer_id FROM users") data["users"] = [dict(r) for r in cur.fetchall()] cur.execute( "SELECT id, name, price_monthly, price_yearly, cpu_seconds_quota, " "is_active FROM plans") data["plans"] = [dict(r) for r in cur.fetchall()] cur.execute( "SELECT id, user_id, deployment_id, plan_id, status, billing_cycle, " "current_period_start, current_period_end FROM subscriptions") data["subscriptions"] = [dict(r) for r in cur.fetchall()] return data finally: conn.close() # ----- import side (pure Odoo; unit-tested) ------------------------------ @api.model def _import_rows(self, data, dry_run=False): """Upsert NexaCloud rows into Odoo. Idempotent. With dry_run=True the writes happen inside a savepoint that is rolled back, so nothing persists.""" if not dry_run: return self._do_import(data) result = {} class _Rollback(Exception): pass try: with self.env.cr.savepoint(): result.update(self._do_import(data)) raise _Rollback() except _Rollback: pass result["dry_run"] = True return result @api.model def _do_import(self, data): return {"created": {}, "updated": {}, "skipped": [], "failed": []} ``` - [ ] **Step 4: Add the wizard view + action + menu** `fusion_centralize_billing/views/import_wizard_views.xml`: ```xml fusion.billing.import.wizard.form fusion.billing.import.wizard
Import from NexaCloud fusion.billing.import.wizard form new
``` - [ ] **Step 5: Wire module imports, security, manifest** Append to `fusion_centralize_billing/__init__.py`: `from . import wizards`. (Confirm it already has `from . import models` and `from . import controllers`; add the wizards line.) Append to `security/ir.model.access.csv`: ``` access_fusion_billing_import_wizard,fusion.billing.import.wizard,model_fusion_billing_import_wizard,base.group_system,1,1,1,1 ``` In `__manifest__.py`, add the view to `data` (after the cron): ```python "data": [ "security/ir.model.access.csv", "data/ir_cron.xml", "views/import_wizard_views.xml", ], ``` - [ ] **Step 6: Verify the module upgrades cleanly on odoo-trial** Run: `bash scripts/fcb_test_on_trial.sh` Expected: `FCB_EXIT=0` (the 39 existing tests still pass; new model/fields/view load with no traceback). - [ ] **Step 7: Commit** ```bash git add fusion_centralize_billing/models/sale_order.py fusion_centralize_billing/models/res_partner.py fusion_centralize_billing/models/__init__.py fusion_centralize_billing/wizards/ fusion_centralize_billing/views/import_wizard_views.xml fusion_centralize_billing/__init__.py fusion_centralize_billing/security/ir.model.access.csv fusion_centralize_billing/__manifest__.py git commit -m "feat(billing): importer scaffold — x_fc fields, wizard, security, view" ``` --- ## Task 2: Identity import (users → partners + links) **Files:** - Modify: `fusion_centralize_billing/wizards/import_wizard.py` - Create: `fusion_centralize_billing/tests/test_importer.py` - Modify: `fusion_centralize_billing/tests/__init__.py` - [ ] **Step 1: Register + write the failing test** Append to `tests/__init__.py`: `from . import test_importer`. `fusion_centralize_billing/tests/test_importer.py`: ```python # -*- coding: utf-8 -*- from odoo.tests.common import TransactionCase, tagged def _fixture(): """Two users, one plan, two subscriptions (monthly + yearly) — the canonical NexaCloud row dicts the importer consumes.""" return { "users": [ {"id": "u-1", "email": "ar@acme.test", "full_name": "Acme Inc", "company": "Acme", "billing_email": "billing@acme.test", "billing_address": "1 Main St", "billing_city": "Toronto", "billing_state": "ON", "billing_postal_code": "M1M1M1", "billing_country": "CA", "tax_id": "123456789RT0001", "stripe_customer_id": "cus_ACME"}, {"id": "u-2", "email": "ops@globex.test", "full_name": "Globex", "company": "Globex", "billing_email": None, "billing_address": None, "billing_city": None, "billing_state": None, "billing_postal_code": None, "billing_country": None, "tax_id": None, "stripe_customer_id": "cus_GLBX"}, ], "plans": [ {"id": "p-1", "name": "Starter", "price_monthly": 20.0, "price_yearly": 200.0, "cpu_seconds_quota": 18000.0, "is_active": True}, ], "subscriptions": [ {"id": "s-1", "user_id": "u-1", "deployment_id": "d-1", "plan_id": "p-1", "status": "active", "billing_cycle": "monthly", "current_period_start": "2026-05-01", "current_period_end": "2026-06-01"}, {"id": "s-2", "user_id": "u-2", "deployment_id": "d-2", "plan_id": "p-1", "status": "active", "billing_cycle": "yearly", "current_period_start": "2026-05-01", "current_period_end": "2027-05-01"}, ], } @tagged('post_install', '-at_install') class TestImporterIdentity(TransactionCase): def setUp(self): super().setUp() self.Wizard = self.env['fusion.billing.import.wizard'].sudo() self.Link = self.env['fusion.billing.account.link'].sudo() def test_imports_users_as_partners_and_links(self): self.Wizard._import_rows({'users': _fixture()['users']}) svc = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')]) self.assertTrue(svc, "importer must find-or-create the nexacloud service") link1 = self.Link.search([('service_id', '=', svc.id), ('external_id', '=', 'u-1')]) self.assertEqual(len(link1), 1) self.assertEqual(link1.partner_id.email, 'billing@acme.test') # billing_email wins self.assertEqual(link1.partner_id.city, 'Toronto') self.assertEqual(link1.partner_id.vat, '123456789RT0001') self.assertEqual(link1.partner_id.x_fc_stripe_customer_id, 'cus_ACME') self.assertEqual(link1.partner_id.country_id.code, 'CA') link2 = self.Link.search([('service_id', '=', svc.id), ('external_id', '=', 'u-2')]) self.assertEqual(link2.partner_id.email, 'ops@globex.test') # falls back to email ``` - [ ] **Step 2: Run it, expect failure** Run: `bash scripts/fcb_test_on_trial.sh` Expected: FAIL — `_do_import` returns the empty stub; no partners/links created. - [ ] **Step 3: Implement service/metric/recurrence helpers + user import** Replace the stub `_do_import` and add helpers in `wizards/import_wizard.py`: ```python @api.model def _fc_service(self): Service = self.env['fusion.billing.service'] svc = Service.search([('code', '=', NEXACLOUD_CODE)], limit=1) return svc or Service.create({'name': 'NexaCloud', 'code': NEXACLOUD_CODE}) @api.model def _fc_cpu_metric(self): Metric = self.env['fusion.billing.metric'] m = Metric.search([('code', '=', CPU_METRIC_CODE)], limit=1) return m or Metric.create({ 'name': 'CPU seconds', 'code': CPU_METRIC_CODE, 'aggregation': 'sum', 'unit_label': 'CPU-seconds'}) @api.model def _fc_recurrence_plan(self, unit): Plan = self.env['sale.subscription.plan'] plan = Plan.search([('billing_period_value', '=', 1), ('billing_period_unit', '=', unit)], limit=1) if plan: return plan label = 'Monthly' if unit == 'month' else 'Yearly' return Plan.create({'name': label, 'billing_period_value': 1, 'billing_period_unit': unit}) @api.model def _fc_resolve_country(self, value): Country = self.env['res.country'] if not value: return Country.browse() v = value.strip() return Country.search(['|', ('code', '=ilike', v), ('name', '=ilike', v)], limit=1) @staticmethod def _bump(summary, created, key): bucket = 'created' if created else 'updated' summary[bucket][key] = summary[bucket].get(key, 0) + 1 @api.model def _import_user(self, service, urow): Link = self.env['fusion.billing.account.link'] ext = str(urow['id']) email = (urow.get('billing_email') or urow.get('email') or '').strip().lower() or None name = urow.get('full_name') or urow.get('company') or email or ext existed = bool(Link.search( [('service_id', '=', service.id), ('external_id', '=', ext)], limit=1)) link = Link._resolve_or_create_partner(service, ext, name=name, email=email) vals = {} if urow.get('billing_address'): vals['street'] = urow['billing_address'] if urow.get('billing_city'): vals['city'] = urow['billing_city'] if urow.get('billing_postal_code'): vals['zip'] = urow['billing_postal_code'] if urow.get('tax_id'): vals['vat'] = urow['tax_id'] if urow.get('stripe_customer_id'): vals['x_fc_stripe_customer_id'] = urow['stripe_customer_id'] country = self._fc_resolve_country(urow.get('billing_country')) if country: vals['country_id'] = country.id if vals: link.partner_id.write(vals) return link, not existed @api.model def _do_import(self, data): service = self._fc_service() summary = {'created': {}, 'updated': {}, 'skipped': [], 'failed': []} partner_by_user = {} for u in data.get('users', []): try: with self.env.cr.savepoint(): link, created = self._import_user(service, u) partner_by_user[str(u['id'])] = link.partner_id self._bump(summary, created, 'partners') except Exception as e: # noqa: BLE001 - per-row isolation summary['failed'].append( {'kind': 'user', 'id': str(u.get('id')), 'error': str(e)}) return summary ``` > **Note:** `partner_by_user` and (Task 3) `plan_ctx_by_id` are **method-local** dicts — never set them as attributes on `self` (Odoo recordsets reject arbitrary attribute assignment). Tasks 3 and 4 add their loops to this same `_do_import` method, so the locals stay in scope. - [ ] **Step 4: Run it, expect pass** Run: `bash scripts/fcb_test_on_trial.sh` Expected: `FCB_EXIT=0`; `TestImporterIdentity` passes. If `country_id.code` assertion fails, fix `_fc_resolve_country` (don't weaken the assertion). - [ ] **Step 5: Commit** ```bash git add fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/tests/test_importer.py fusion_centralize_billing/tests/__init__.py git commit -m "feat(billing): importer identity (NexaCloud users -> partners + links)" ``` --- ## Task 3: Catalog import (plans → metric + products + charge, plan_id NULL) **Files:** - Modify: `fusion_centralize_billing/wizards/import_wizard.py` - Modify: `fusion_centralize_billing/tests/test_importer.py` - [ ] **Step 1: Write the failing test** (append to `test_importer.py`) ```python @tagged('post_install', '-at_install') class TestImporterCatalog(TransactionCase): def setUp(self): super().setUp() self.Wizard = self.env['fusion.billing.import.wizard'].sudo() def test_imports_plan_as_charge_with_null_plan_id(self): self.Wizard._import_rows({'plans': _fixture()['plans']}) metric = self.env['fusion.billing.metric'].search([('code', '=', 'cpu_seconds')]) self.assertTrue(metric) charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')]) self.assertEqual(len(charge), 1) self.assertEqual(charge.metric_id, metric) self.assertEqual(charge.included_quota, 18000.0) # = plan.cpu_seconds_quota self.assertEqual(charge.unit_batch, 3600.0) # one core-hour self.assertAlmostEqual(charge.price_per_unit, 0.0075) # CAD per core-hour self.assertEqual(charge.charge_model, 'standard') self.assertFalse(charge.plan_id, "shadow: charge.plan_id must be NULL so the " "rating cron never auto-mutates order lines") self.assertTrue(charge.product_id, "charge needs an overage product") self.assertTrue(charge.product_id.recurring_invoice is False or charge.product_id.recurring_invoice in (False, None)) def test_charge_math_matches_nexacloud(self): # 18000 quota + 2 core-hours overage (7200s) -> 2 batches * $0.0075 = $0.015 self.Wizard._import_rows({'plans': _fixture()['plans']}) charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')]) _overage, amount = charge._compute_billable(18000.0 + 7200.0) self.assertAlmostEqual(amount, 0.015, places=4) ``` - [ ] **Step 2: Run it, expect failure** Run: `bash scripts/fcb_test_on_trial.sh` Expected: FAIL — no charge created (catalog import not implemented). - [ ] **Step 3: Implement catalog import** Add to `wizards/import_wizard.py`: ```python @api.model def _import_plan(self, metric, prow): Product = self.env['product.product'] Charge = self.env['fusion.billing.charge'] plan_code = str(prow['id']) name = prow.get('name') or plan_code price_monthly = float(prow.get('price_monthly') or 0.0) price_yearly = float(prow.get('price_yearly') or 0.0) sub_code = 'NC-PLAN-%s' % plan_code sub_product = Product.search([('default_code', '=', sub_code)], limit=1) created = False if not sub_product: sub_product = Product.create({ 'name': 'NexaCloud %s' % name, 'default_code': sub_code, 'type': 'service', 'recurring_invoice': True, 'list_price': price_monthly}) created = True ov_code = 'NC-CPU-OVG-%s' % plan_code ov_product = Product.search([('default_code', '=', ov_code)], limit=1) if not ov_product: ov_product = Product.create({ 'name': 'NexaCloud CPU overage (%s)' % name, 'default_code': ov_code, 'type': 'service', 'list_price': 0.0}) charge_vals = { 'name': 'NexaCloud CPU overage — %s' % name, 'plan_code': plan_code, 'metric_id': metric.id, 'product_id': ov_product.id, 'included_quota': float(prow.get('cpu_seconds_quota') or 0.0), 'price_per_unit': CPU_RATE_PER_CORE_HOUR, 'unit_batch': CPU_SECONDS_PER_CORE_HOUR, 'charge_model': 'standard', # plan_id intentionally omitted (NULL) — shadow safety guarantee #3 } charge = Charge.search( [('plan_code', '=', plan_code), ('metric_id', '=', metric.id)], limit=1) if charge: charge.write(charge_vals) else: charge = Charge.create(charge_vals) created = True return {'sub_product': sub_product, 'overage_product': ov_product, 'charge': charge, 'price_monthly': price_monthly, 'price_yearly': price_yearly}, created ``` In `_do_import`, after the users loop, add the plans loop: ```python metric = self._fc_cpu_metric() plan_ctx_by_id = {} for p in data.get('plans', []): try: with self.env.cr.savepoint(): ctx, created = self._import_plan(metric, p) plan_ctx_by_id[str(p['id'])] = ctx self._bump(summary, created, 'plans') except Exception as e: # noqa: BLE001 summary['failed'].append( {'kind': 'plan', 'id': str(p.get('id')), 'error': str(e)}) ``` - [ ] **Step 4: Run it, expect pass** Run: `bash scripts/fcb_test_on_trial.sh` Expected: `FCB_EXIT=0`; both catalog tests pass. If `product.product` rejects `recurring_invoice` or `type='service'`, read the field on odoo-trial and fix the source. - [ ] **Step 5: Commit** ```bash git add fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/tests/test_importer.py git commit -m "feat(billing): importer catalog (plans -> products + CPU charge, plan_id NULL)" ``` --- ## Task 4: Subscription import (deployments → draft shadow sale.order) **Files:** - Modify: `fusion_centralize_billing/wizards/import_wizard.py` - Modify: `fusion_centralize_billing/tests/test_importer.py` - [ ] **Step 1: Write the failing test** (append to `test_importer.py`) ```python @tagged('post_install', '-at_install') class TestImporterSubscriptions(TransactionCase): def setUp(self): super().setUp() self.Wizard = self.env['fusion.billing.import.wizard'].sudo() def test_imports_one_draft_shadow_subscription_per_deployment(self): self.Wizard._import_rows(_fixture()) SaleOrder = self.env['sale.order'] sub1 = SaleOrder.search([('x_fc_nexacloud_subscription_id', '=', 's-1')]) self.assertEqual(len(sub1), 1) self.assertTrue(sub1.is_subscription) self.assertTrue(sub1.x_fc_shadow) self.assertEqual(sub1.x_fc_nexacloud_deployment_id, 'd-1') self.assertNotEqual(sub1.subscription_state, '3_progress') # left in draft # monthly flat price set explicitly on the plan product line plan_line = sub1.order_line.filtered( lambda l: l.product_id.default_code == 'NC-PLAN-p-1') self.assertEqual(len(plan_line), 1) self.assertAlmostEqual(plan_line.price_unit, 20.0) # price_monthly # the yearly subscription gets the yearly price + yearly recurrence sub2 = SaleOrder.search([('x_fc_nexacloud_subscription_id', '=', 's-2')]) line2 = sub2.order_line.filtered(lambda l: l.product_id.default_code == 'NC-PLAN-p-1') self.assertAlmostEqual(line2.price_unit, 200.0) # price_yearly self.assertEqual(sub2.plan_id.billing_period_unit, 'year') def test_subscription_skipped_when_user_or_plan_unresolved(self): data = _fixture() data['subscriptions'].append( {"id": "s-3", "user_id": "u-missing", "deployment_id": "d-3", "plan_id": "p-1", "status": "active", "billing_cycle": "monthly", "current_period_start": "2026-05-01", "current_period_end": "2026-06-01"}) summary = self.Wizard._import_rows(data) self.assertFalse(self.env['sale.order'].search( [('x_fc_nexacloud_subscription_id', '=', 's-3')])) self.assertTrue(any(s.get('id') == 's-3' for s in summary['skipped'])) ``` - [ ] **Step 2: Run it, expect failure** Run: `bash scripts/fcb_test_on_trial.sh` Expected: FAIL — no subscriptions created (subscription import not implemented). - [ ] **Step 3: Implement subscription import** Add to `wizards/import_wizard.py`: ```python @api.model def _import_subscription(self, service, partner, plan_ctx, recurrence_plans, srow): SaleOrder = self.env['sale.order'] SaleOrderLine = self.env['sale.order.line'] sub_ext = str(srow['id']) cycle = (srow.get('billing_cycle') or 'monthly').lower() rec_plan = recurrence_plans['yearly'] if cycle == 'yearly' else recurrence_plans['monthly'] price = plan_ctx['price_yearly'] if cycle == 'yearly' else plan_ctx['price_monthly'] product = plan_ctx['sub_product'] order_vals = { 'partner_id': partner.id, 'plan_id': rec_plan.id, 'x_fc_nexacloud_subscription_id': sub_ext, 'x_fc_nexacloud_deployment_id': str(srow.get('deployment_id') or ''), 'x_fc_billing_service_id': service.id, 'x_fc_shadow': True, } existing = SaleOrder.search( [('x_fc_nexacloud_subscription_id', '=', sub_ext)], limit=1) if existing: existing.write(order_vals) line = existing.order_line.filtered(lambda l: l.product_id == product) line_vals = {'product_uom_qty': 1, 'price_unit': price} if line: line.write(line_vals) else: SaleOrderLine.create(dict(order_id=existing.id, product_id=product.id, **line_vals)) order = existing created = False else: order_vals['order_line'] = [(0, 0, { 'product_id': product.id, 'product_uom_qty': 1, 'price_unit': price})] order = SaleOrder.create(order_vals) created = True # guarantee the explicit price stuck (a pricelist compute may have overwritten it) line = order.order_line.filtered(lambda l: l.product_id == product) if line and line.price_unit != price: line.price_unit = price return order, created ``` In `_do_import`, before `return summary`, add the recurrences + subscriptions loop: ```python recurrence_plans = {'monthly': self._fc_recurrence_plan('month'), 'yearly': self._fc_recurrence_plan('year')} for s in data.get('subscriptions', []): partner = partner_by_user.get(str(s.get('user_id') or '')) ctx = plan_ctx_by_id.get(str(s.get('plan_id') or '')) if not partner or not ctx: summary['skipped'].append({ 'kind': 'subscription', 'id': str(s.get('id')), 'reason': 'unresolved %s' % ('user' if not partner else 'plan')}) continue try: with self.env.cr.savepoint(): _order, created = self._import_subscription( service, partner, ctx, recurrence_plans, s) self._bump(summary, created, 'subscriptions') except Exception as e: # noqa: BLE001 summary['failed'].append( {'kind': 'subscription', 'id': str(s.get('id')), 'error': str(e)}) ``` - [ ] **Step 4: Run it, expect pass** Run: `bash scripts/fcb_test_on_trial.sh` Expected: `FCB_EXIT=0`. If `is_subscription` is False on the draft order, that disproves the design assumption — read `sale_order.py` in `sale_subscription` on odoo-trial and adjust how the subscription is created (e.g. set the field driving `is_subscription`), never weaken the assertion. If `billing_period_unit` rejects `'year'`, read the selection values and fix `_fc_recurrence_plan`. - [ ] **Step 5: Commit** ```bash git add fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/tests/test_importer.py git commit -m "feat(billing): importer subscriptions (one draft shadow sale.order per deployment)" ``` --- ## Task 5: Idempotency + dry-run **Files:** - Modify: `fusion_centralize_billing/tests/test_importer.py` - [ ] **Step 1: Write the failing test** (append to `test_importer.py`) ```python @tagged('post_install', '-at_install') class TestImporterIdempotencyDryRun(TransactionCase): def setUp(self): super().setUp() self.Wizard = self.env['fusion.billing.import.wizard'].sudo() def _counts(self): return ( self.env['fusion.billing.account.link'].search_count([]), self.env['fusion.billing.charge'].search_count([]), self.env['sale.order'].search_count([('x_fc_shadow', '=', True)]), ) def test_rerun_updates_not_duplicates(self): self.Wizard._import_rows(_fixture()) before = self._counts() # change a value and re-run; counts stay the same, value updates data = _fixture() data['plans'][0]['cpu_seconds_quota'] = 99999.0 self.Wizard._import_rows(data) self.assertEqual(self._counts(), before, "re-run must upsert, not duplicate") charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')]) self.assertEqual(charge.included_quota, 99999.0) def test_dry_run_writes_nothing(self): summary = self.Wizard._import_rows(_fixture(), dry_run=True) self.assertTrue(summary.get('dry_run')) self.assertEqual(self._counts(), (0, 0, 0), "dry-run must not persist anything") # the nexacloud service is created inside the rolled-back savepoint too self.assertFalse(self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')])) ``` - [ ] **Step 2: Run it, expect pass** Run: `bash scripts/fcb_test_on_trial.sh` Expected: `FCB_EXIT=0` — idempotency and dry-run already hold from Tasks 2–4 + the savepoint in `_import_rows`. If the dry-run leaves a `nexacloud` service behind, the savepoint isn't wrapping `_fc_service` — confirm `_do_import` (which creates the service) runs entirely inside the `with self.env.cr.savepoint()` block. - [ ] **Step 3: Commit** ```bash git add fusion_centralize_billing/tests/test_importer.py git commit -m "test(billing): importer idempotency + dry-run" ``` --- ## Task 6: Shadow-mode safety assertions **Files:** - Modify: `fusion_centralize_billing/tests/test_importer.py` - [ ] **Step 1: Write the failing test** (append to `test_importer.py`) ```python @tagged('post_install', '-at_install') class TestImporterShadowSafety(TransactionCase): def setUp(self): super().setUp() self.Wizard = self.env['fusion.billing.import.wizard'].sudo() def test_import_creates_no_invoice_and_no_payment_token(self): self.Wizard._import_rows(_fixture()) subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)]) self.assertTrue(subs) partners = subs.mapped('partner_id') # no posted/draft customer invoice for any imported partner invoices = self.env['account.move'].search([ ('partner_id', 'in', partners.ids), ('move_type', '=', 'out_invoice')]) self.assertFalse(invoices, "shadow import must not create any invoice") # no Stripe payment token -> charging is physically impossible tokens = self.env['payment.token'].search([('partner_id', 'in', partners.ids)]) self.assertFalse(tokens, "shadow import must not attach a payment token") # every imported charge has a NULL plan_id so the rating cron skips it charges = self.env['fusion.billing.charge'].search([('plan_code', 'like', 'p-%')]) self.assertTrue(charges) self.assertFalse(any(charges.mapped('plan_id'))) def test_rating_cron_leaves_shadow_subscriptions_untouched(self): self.Wizard._import_rows(_fixture()) subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)]) lines_before = sum(len(s.order_line) for s in subs) self.env['fusion.billing.usage']._cron_rate_open_periods() subs.invalidate_recordset() lines_after = sum(len(s.order_line) for s in subs) self.assertEqual(lines_before, lines_after, "charges with NULL plan_id must keep the rating cron a no-op") ``` - [ ] **Step 2: Run it, expect pass** Run: `bash scripts/fcb_test_on_trial.sh` Expected: `FCB_EXIT=0` — the safety properties hold by construction (draft, no token, NULL plan_id). If `payment.token` is not a valid model name in this build, read the `payment` model names on odoo-trial and use the correct one (don't drop the assertion). If an invoice *is* found, the draft-import guarantee is broken — investigate whether `sale.order.create` auto-invoices, and stop confirming/posting. - [ ] **Step 3: Commit** ```bash git add fusion_centralize_billing/tests/test_importer.py git commit -m "test(billing): importer shadow-mode safety (no invoice/token, cron no-op)" ``` --- ## Task 7: Error handling — malformed rows isolated **Files:** - Modify: `fusion_centralize_billing/tests/test_importer.py` - [ ] **Step 1: Write the failing test** (append to `test_importer.py`) ```python @tagged('post_install', '-at_install') class TestImporterErrorIsolation(TransactionCase): def setUp(self): super().setUp() self.Wizard = self.env['fusion.billing.import.wizard'].sudo() def test_one_bad_user_does_not_abort_the_batch(self): data = _fixture() # a row with no id -> str(urow['id']) raises KeyError, must be caught per-row data['users'].insert(0, {"email": "broken@x.test"}) summary = self.Wizard._import_rows(data) # the two good users still import self.assertEqual( self.env['fusion.billing.account.link'].search_count([]), 2) self.assertTrue(summary['failed'], "the bad row must be recorded in failed[]") self.assertTrue(any(f['kind'] == 'user' for f in summary['failed'])) ``` - [ ] **Step 2: Run it, expect pass** Run: `bash scripts/fcb_test_on_trial.sh` Expected: `FCB_EXIT=0` — the per-row `try/except` + `savepoint` already isolates failures. If the whole batch aborts, the `savepoint` is missing around `_import_user` or the broad `except` is too narrow — fix so one bad row never poisons the cursor. - [ ] **Step 3: Commit** ```bash git add fusion_centralize_billing/tests/test_importer.py git commit -m "test(billing): importer per-row error isolation" ``` --- ## Task 8: Read path — DSN guard **Files:** - Modify: `fusion_centralize_billing/tests/test_importer.py` - [ ] **Step 1: Write the failing test** (append to `test_importer.py`) ```python from odoo.exceptions import UserError @tagged('post_install', '-at_install') class TestImporterReadGuard(TransactionCase): def test_missing_dsn_raises_usererror(self): # ensure no DSN is configured in the test DB self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '') wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True}) with self.assertRaises(UserError): wiz._read_nexacloud_rows() ``` - [ ] **Step 2: Run it, expect pass** Run: `bash scripts/fcb_test_on_trial.sh` Expected: `FCB_EXIT=0` — `_read_nexacloud_rows` raises `UserError` when the DSN param is empty (implemented in Task 1). If `psycopg2` import fails on odoo-trial, confirm it ships with the image (it does — Odoo depends on it). - [ ] **Step 3: Commit** ```bash git add fusion_centralize_billing/tests/test_importer.py git commit -m "test(billing): importer read-path DSN guard" ``` --- ## Task 9: Full suite + static checks **Files:** none (verification task) - [ ] **Step 1: Full test run** Run: `bash scripts/fcb_test_on_trial.sh` Expected: `FCB_EXIT=0`, no `FAIL`/`ERROR` lines for `fusion_centralize_billing`. - [ ] **Step 2: No `_sql_constraints` regressions** Run: `grep -rn "_sql_constraints" fusion_centralize_billing/ || echo "clean"` Expected: `clean`. - [ ] **Step 3: No bare `sale.subscription` model references** Run: `grep -rnE "sale\.subscription[^.]" fusion_centralize_billing/ || echo "clean"` Expected: `clean` (only `sale.subscription.plan` is valid). - [ ] **Step 4: Pyflakes the new Python** Run: `docker exec odoo-modsdev-app python3 -m pyflakes fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/models/res_partner.py 2>&1 | tail -20 || true` Expected: no undefined names (catches the kind of `_norm_email` NameError the helpdesk smoke test missed). - [ ] **Step 5: Commit (if any fixes)** ```bash git add -A fusion_centralize_billing/ git commit -m "test(billing): 2a importer full suite green + static checks" ``` --- ## Done = 2a importer complete A NexaCloud backfill produces, idempotently: unified partners + links, a `cpu_seconds` charge catalog (`plan_id` NULL), and one draft shadow `sale.order` per deployment carrying the exact NexaCloud flat price — with zero customer-visible billing in Odoo (no invoice, no token, rating cron a no-op). The `psycopg2` read path is ready; the live run is gated only on the read-only DSN grant. ## Next (not this plan) - 2b: NexaCloud `usage_metering.py` pushes cpu-seconds (= core-hours × 3600) to `POST /usage`. - 2c: NexaCloud consumes `invoice.payment_failed` / `subscription.terminated` webhooks → throttle/deprovision. - 2d: `fusion.billing.reconciliation` diffs Odoo-computed (flat + `charge._compute_billable`) vs NexaCloud actuals per period; flip when within tolerance (set `charge.plan_id`, attach tokens, confirm subs).