From 40b3205274f86f5bc73b665e417bd8fb29241e3c Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 13:25:26 -0400 Subject: [PATCH] docs(billing): TDD implementation plan for 2a NexaCloud importer 9 task-by-task plan: x_fc fields + wizard scaffold, identity, catalog (plan_id NULL), draft shadow subscriptions, idempotency+dry-run, shadow-safety assertions, per-row error isolation, DSN read guard, full suite + static checks. Tests run on odoo-trial. --- .../2026-05-27-nexacloud-billing-importer.md | 956 ++++++++++++++++++ 1 file changed, 956 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-nexacloud-billing-importer.md diff --git a/docs/superpowers/plans/2026-05-27-nexacloud-billing-importer.md b/docs/superpowers/plans/2026-05-27-nexacloud-billing-importer.md new file mode 100644 index 00000000..a827e021 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-nexacloud-billing-importer.md @@ -0,0 +1,956 @@ +# 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).