Files
Odoo-Modules/docs/superpowers/plans/2026-05-27-nexacloud-billing-importer.md
gsinghpal 40b3205274 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 13:25:26 -04:00

41 KiB
Raw Blame History

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 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, ~12 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):

    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:

# -*- 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:

from . import import_wizard

fusion_centralize_billing/wizards/import_wizard.py:

# -*- 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 version="1.0" encoding="utf-8"?>
<odoo>
    <record id="view_fusion_billing_import_wizard_form" model="ir.ui.view">
        <field name="name">fusion.billing.import.wizard.form</field>
        <field name="model">fusion.billing.import.wizard</field>
        <field name="arch" type="xml">
            <form string="Import from NexaCloud">
                <group>
                    <field name="dry_run"/>
                </group>
                <group string="Result" invisible="not result_summary">
                    <field name="result_summary" nolabel="1" widget="text"/>
                </group>
                <footer>
                    <button name="action_run_import" type="object" string="Run Import"
                            class="btn-primary"/>
                    <button string="Close" class="btn-secondary" special="cancel"/>
                </footer>
            </form>
        </field>
    </record>

    <record id="action_fusion_billing_import_wizard" model="ir.actions.act_window">
        <field name="name">Import from NexaCloud</field>
        <field name="res_model">fusion.billing.import.wizard</field>
        <field name="view_mode">form</field>
        <field name="target">new</field>
    </record>

    <menuitem id="menu_fusion_billing_root" name="Fusion Billing"
              parent="account.menu_finance" sequence="90"/>
    <menuitem id="menu_fusion_billing_import" name="Import from NexaCloud"
              parent="menu_fusion_billing_root"
              action="action_fusion_billing_import_wizard" sequence="10"
              groups="base.group_system"/>
</odoo>
  • 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):

    "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
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"

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:

# -*- 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:

    @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
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)

@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:

    @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:

        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
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)

@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:

    @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:

        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
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)

@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 24 + 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
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)

@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
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)

@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
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)

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
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)
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).