Files
Odoo-Modules/docs/superpowers/plans/2026-05-27-nexacloud-invoice-ledger.md

29 KiB
Raw Blame History

NexaCloud → Odoo Invoice Ledger — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (- [ ]) syntax.

Goal: Ingest NexaCloud's real (Stripe-billed) invoices into Odoo as posted account.move customer invoices with reconciled payments + HST, so Odoo is the accounting system of record — all history + ongoing, revenue split by service family, draft-first on the live books.

Architecture: A new ingester in fusion_centralize_billing mirroring the importer's read/write split: _read_nexacloud_invoices (read-only psycopg2 via the existing DSN) → _ingest_invoices (pure Odoo: create account.move drafts idempotently, map lines to per-family income accounts, derive tax, reconcile Stripe payments) → _post_ingested (bulk-post after review). Reuses the account.link partner mapping. Native Odoo accounting does the rest.

Tech Stack: Odoo 19 Enterprise, account_accountant, psycopg2. Tests: TransactionCase on odoo-trial (bash scripts/fcb_test_on_trial.sh, pass = FCB_EXIT=0).

Spec: docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md


Conventions

  • Never code accounting internals from memory (CLAUDE rule #1). Reference confirmed on trial: account.move has invoice_line_ids/invoice_date/action_post; account.payment.register exists; account_type='income'/'asset_receivable' valid; sale taxes are Canadian (find HST 13% by amount=13 / name). Where a step says "read reference", confirm before relying on it.
  • Models, not UI: logic in model methods; the wizard only calls them. Testable under TransactionCase.
  • New fields on native models: x_fc_*. Declarative models.Constraint only.
  • Tests run on odoo-trial (bash scripts/fcb_test_on_trial.sh, full suite, ~12 min). Register each new tests/test_*.py in tests/__init__.py in the same task.

File structure

fusion_centralize_billing/
  models/
    account_move.py            # NEW: account.move inherit (x_fc_nexacloud_invoice_id, x_fc_stripe_invoice_id)
    __init__.py                # + account_move
  wizards/
    invoice_ledger.py          # NEW: the ingester (read + ingest + post + family/tax/payment helpers)
    __init__.py                # + invoice_ledger
  views/
    invoice_ledger_views.xml   # NEW: wizard form + action + menu + cron
  security/ir.model.access.csv # + ledger wizard ACL
  __manifest__.py              # + views/invoice_ledger_views.xml
  tests/
    test_invoice_ledger.py     # NEW
    __init__.py                # + test_invoice_ledger

Task 1: Scaffold — account.move fields + ledger wizard skeleton

Files: create models/account_move.py, wizards/invoice_ledger.py, views/invoice_ledger_views.xml; modify models/__init__.py, wizards/__init__.py, security/ir.model.access.csv, __manifest__.py.

  • Step 1: account.move inheritmodels/account_move.py:
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import fields, models


class AccountMove(models.Model):
    _inherit = "account.move"

    x_fc_nexacloud_invoice_id = fields.Char(
        index=True, copy=False, help="Source NexaCloud invoice id — ledger idempotency key.")
    x_fc_stripe_invoice_id = fields.Char(index=True, copy=False)

    _fc_nc_invoice_uniq = models.Constraint(
        "unique(x_fc_nexacloud_invoice_id)",
        "One Odoo invoice per NexaCloud invoice id.")

Add from . import account_move to models/__init__.py.

  • Step 2: ledger wizard skeletonwizards/invoice_ledger.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__)


class FusionBillingInvoiceLedgerWizard(models.TransientModel):
    _name = "fusion.billing.invoice.ledger.wizard"
    _description = "Fusion Billing — NexaCloud Invoice Ledger Ingester"

    dry_run = fields.Boolean(default=True)
    auto_post = fields.Boolean(
        default=False, help="Post invoices immediately (else leave draft for review).")
    result_summary = fields.Text(readonly=True)

    def _ingest_invoices(self, data, post=False):
        return {"created": 0, "updated": 0, "posted": 0, "skipped": [], "failed": [], "by_family": {}}

Add from . import invoice_ledger to wizards/__init__.py.

  • Step 3: view + action + menuviews/invoice_ledger_views.xml:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <record id="view_fc_invoice_ledger_wizard_form" model="ir.ui.view">
        <field name="name">fusion.billing.invoice.ledger.wizard.form</field>
        <field name="model">fusion.billing.invoice.ledger.wizard</field>
        <field name="arch" type="xml">
            <form string="Ingest NexaCloud Invoices">
                <group>
                    <field name="dry_run"/>
                    <field name="auto_post"/>
                </group>
                <group string="Result" invisible="not result_summary">
                    <field name="result_summary" nolabel="1" widget="text"/>
                </group>
                <footer>
                    <button name="action_run" type="object" string="Run" class="btn-primary"/>
                    <button string="Close" class="btn-secondary" special="cancel"/>
                </footer>
            </form>
        </field>
    </record>
    <record id="action_fc_invoice_ledger_wizard" model="ir.actions.act_window">
        <field name="name">Ingest NexaCloud Invoices</field>
        <field name="res_model">fusion.billing.invoice.ledger.wizard</field>
        <field name="view_mode">form</field>
        <field name="target">new</field>
    </record>
    <menuitem id="menu_fc_invoice_ledger" name="Ingest NexaCloud Invoices"
              parent="menu_fusion_billing_root"
              action="action_fc_invoice_ledger_wizard" sequence="20"
              groups="base.group_system"/>
</odoo>
  • Step 4: security + manifest — append to security/ir.model.access.csv:
access_fc_invoice_ledger_wizard,fusion.billing.invoice.ledger.wizard,model_fusion_billing_invoice_ledger_wizard,base.group_system,1,1,1,1

Add "views/invoice_ledger_views.xml" to __manifest__.py data.

  • Step 5: verify upgradebash scripts/fcb_test_on_trial.shFCB_EXIT=0 (existing tests pass; new model/fields/view load).

  • Step 6: commitfeat(billing): invoice-ledger scaffold (account.move x_fc fields + wizard)


Task 2: Service-family classification + income account

Files: modify wizards/invoice_ledger.py; create tests/test_invoice_ledger.py (+ register in tests/__init__.py).

  • Step 1: failing testtests/test_invoice_ledger.py:
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged


@tagged('post_install', '-at_install')
class TestLedgerFamily(TransactionCase):

    def setUp(self):
        super().setUp()
        self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()

    def test_family_classification(self):
        f = self.W._fc_family_for
        self.assertEqual(f('Odoo ERP Hosting (2026-05-01 to 2026-06-01)'), 'hosting')
        self.assertEqual(f('WordPress Website Hosting - Managed (at $50.00 / month)'), 'hosting')
        self.assertEqual(f('Managed Odoo - Standard (at $49.99 / month)'), 'managed')
        self.assertEqual(f('Daily Backup Protection'), 'addons')
        self.assertEqual(f('Remaining time on Daily Backup Protection after 27 May 2026'), 'addons')
        self.assertEqual(f('Something Unmapped'), 'other')

    def test_income_account_per_family_distinct(self):
        a_host = self.W._fc_income_account('hosting')
        a_add = self.W._fc_income_account('addons')
        self.assertEqual(a_host.account_type, 'income')
        self.assertNotEqual(a_host, a_add)               # split by family
        self.assertEqual(self.W._fc_income_account('hosting'), a_host)  # idempotent

Append from . import test_invoice_ledger to tests/__init__.py.

  • Step 2: run → FAIL (_fc_family_for missing).

  • Step 3: implement — in wizards/invoice_ledger.py:

    _FAMILY_KEYWORDS = [
        ('hosting', ['odoo erp hosting', 'wordpress website hosting']),
        ('managed', ['managed']),
        ('addons', ['daily backup', 'whatsapp', 'forms builder', 'white label']),
    ]

    @api.model
    def _fc_family_for(self, description):
        import re
        d = (description or '').lower()
        m = re.match(r'remaining time on (.+?)(?: after| from |\s*\()', d)
        if m:
            d = m.group(1)  # classify proration by the prorated item
        for fam, kws in self._FAMILY_KEYWORDS:
            if any(k in d for k in kws):
                return fam
        return 'other'

    @api.model
    def _fc_income_account(self, family):
        Account = self.env['account.account']
        code = 'NCR-' + family.upper()[:6]
        acc = Account.search([('code', '=', code)], limit=1)
        if not acc:
            acc = Account.create({
                'code': code, 'name': 'NexaCloud %s Revenue' % family.title(),
                'account_type': 'income'})
        return acc
  • Step 4: run → PASS. (If account.account.create needs more required fields on this build, read account_account.py on trial and add them — don't weaken the test.)

  • Step 5: commitfeat(billing): ledger service-family classification + per-family income accounts


Task 3: Tax derivation (match NexaCloud's invoice.tax)

Files: modify wizards/invoice_ledger.py, tests/test_invoice_ledger.py.

  • Step 1: failing test (append):
@tagged('post_install', '-at_install')
class TestLedgerTax(TransactionCase):

    def setUp(self):
        super().setUp()
        self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()

    def test_tax_for_13pct_is_a_13_percent_sale_tax(self):
        tax = self.W._fc_tax_for(100.0, 13.0)
        self.assertTrue(tax, "expected an HST/13% sale tax on the Canadian COA")
        self.assertEqual(tax.type_tax_use, 'sale')
        # the chosen tax computes 13.00 on 100.00
        res = tax.compute_all(100.0)
        self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 13.0, places=2)

    def test_tax_for_zero_is_zero_or_empty(self):
        tax = self.W._fc_tax_for(100.0, 0.0)
        if tax:
            res = tax.compute_all(100.0)
            self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 0.0, places=2)
  • Step 2: run → FAIL.

  • Step 3: implement:

    @api.model
    def _fc_tax_for(self, subtotal, tax_amount):
        """Map a NexaCloud invoice's (subtotal, tax_amount) to the Odoo sale tax whose
        computed tax equals it. Picks by effective percent; falls back to a 0% sale tax."""
        Tax = self.env['account.tax']
        sub = float(subtotal or 0.0)
        tax_amt = float(tax_amount or 0.0)
        if sub <= 0 or tax_amt <= 0:
            return Tax.search([('type_tax_use', '=', 'sale'), ('amount', '=', 0.0)], limit=1)
        rate = round(100.0 * tax_amt / sub)
        tax = Tax.search([('type_tax_use', '=', 'sale'), ('amount_type', '=', 'percent'),
                          ('amount', '=', float(rate))], limit=1)
        if not tax:
            tax = Tax.search([('type_tax_use', '=', 'sale'), ('name', 'ilike', '%s' % rate)], limit=1)
        return tax
  • Step 4: run → PASS. (Read reference if no 13% sale tax exists: docker exec odoo-trial-app ... grep -i hst the l10n_ca data; on nexamain confirm the HST 13% record from nexa_coa_setup.)

  • Step 5: commitfeat(billing): ledger tax derivation matching source invoice tax


Task 4: Ingest invoices → draft account.move (idempotent)

Read reference first:

ssh pve-worker1 "qm guest exec 316 -- bash -lc 'docker exec odoo-trial-app bash -lc \"grep -nE \\\"def action_post|invoice_line_ids|move_type\\\" /mnt/enterprise-addons/account_accountant/../account/models/account_move.py | head\"'"

Confirm account.move.create({'move_type':'out_invoice','partner_id':..,'invoice_line_ids':[(0,0,{'name','quantity','price_unit','account_id','tax_ids'})]}) and move.amount_untaxed/amount_tax/amount_total.

Files: modify wizards/invoice_ledger.py, tests/test_invoice_ledger.py.

  • Step 1: failing test (append) — uses a fixture invoice dict shaped like _read_nexacloud_invoices output:
def _inv_fixture():
    return [{
        'id': 'inv-1', 'stripe_invoice_id': 'in_test1', 'invoice_number': 'NEX-0001',
        'user_external_id': 'u-1', 'partner_name': 'Acme', 'partner_email': 'ar@acme.test',
        'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'open',
        'subtotal': 100.0, 'tax': 13.0, 'amount_paid': 0.0, 'paid_at': None,
        'items': [{'description': 'Odoo ERP Hosting (2026-05-01 to 2026-06-01)',
                   'quantity': 1.0, 'unit_price': 100.0, 'amount': 100.0}],
    }]


@tagged('post_install', '-at_install')
class TestLedgerIngest(TransactionCase):

    def setUp(self):
        super().setUp()
        self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
        self.svc = self.env['fusion.billing.service'].sudo().create(
            {'name': 'NexaCloud', 'code': 'nexacloud'})

    def test_ingest_creates_draft_invoice_with_right_totals(self):
        self.W._ingest_invoices(_inv_fixture(), post=False)
        mv = self.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
        self.assertEqual(len(mv), 1)
        self.assertEqual(mv.move_type, 'out_invoice')
        self.assertEqual(mv.state, 'draft')
        self.assertAlmostEqual(mv.amount_untaxed, 100.0, places=2)
        self.assertAlmostEqual(mv.amount_tax, 13.0, places=2)      # equals source tax
        self.assertAlmostEqual(mv.amount_total, 113.0, places=2)
        self.assertEqual(mv.partner_id.email, 'ar@acme.test')
        line = mv.invoice_line_ids
        self.assertEqual(line.account_id, self.W._fc_income_account('hosting'))

    def test_ingest_is_idempotent(self):
        self.W._ingest_invoices(_inv_fixture(), post=False)
        self.W._ingest_invoices(_inv_fixture(), post=False)
        self.assertEqual(self.env['account.move'].search_count(
            [('x_fc_nexacloud_invoice_id', '=', 'inv-1')]), 1)
  • Step 2: run → FAIL.

  • Step 3: implement the partner resolver + _ingest_invoices:

    @api.model
    def _fc_partner_for(self, inv):
        """Resolve the unified partner for an invoice via the nexacloud account.link
        (by user_external_id); create partner+link if missing (covers NULL-subscription
        invoices, which still carry a user)."""
        service = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')], limit=1)
        link = self.env['fusion.billing.account.link']._resolve_or_create_partner(
            service, str(inv.get('user_external_id')),
            name=inv.get('partner_name'), email=inv.get('partner_email'))
        return link.partner_id

    @api.model
    def _ingest_invoices(self, data, post=False):
        Move = self.env['account.move']
        cad = self.env.ref('base.CAD', raise_if_not_found=False) or self.env.company.currency_id
        summary = {'created': 0, 'updated': 0, 'posted': 0, 'skipped': [], 'failed': [], 'by_family': {}}
        for inv in data:
            nc_id = str(inv.get('id') or '')
            try:
                with self.env.cr.savepoint():
                    existing = Move.search([('x_fc_nexacloud_invoice_id', '=', nc_id)], limit=1)
                    if existing:
                        if existing.state != 'draft':
                            summary['skipped'].append({'id': nc_id, 'reason': 'already posted'})
                            continue
                        existing.invoice_line_ids.unlink()  # draft: replace lines
                        move = existing
                    else:
                        move = Move.create({
                            'move_type': 'out_invoice',
                            'partner_id': self._fc_partner_for(inv).id,
                            'invoice_date': inv.get('invoice_date'),
                            'ref': inv.get('invoice_number'),
                            'currency_id': cad.id,
                            'x_fc_nexacloud_invoice_id': nc_id,
                            'x_fc_stripe_invoice_id': inv.get('stripe_invoice_id'),
                        })
                    tax = self._fc_tax_for(inv.get('subtotal'), inv.get('tax'))
                    line_vals = []
                    for it in inv.get('items', []):
                        fam = self._fc_family_for(it.get('description'))
                        summary['by_family'][fam] = round(
                            summary['by_family'].get(fam, 0.0) + float(it.get('amount') or 0.0), 2)
                        line_vals.append((0, 0, {
                            'name': it.get('description') or 'NexaCloud',
                            'quantity': float(it.get('quantity') or 1.0),
                            'price_unit': float(it.get('unit_price') or it.get('amount') or 0.0),
                            'account_id': self._fc_income_account(fam).id,
                            'tax_ids': [(6, 0, tax.ids)] if tax else [(5, 0, 0)],
                        }))
                    move.write({'invoice_line_ids': line_vals})
                    summary['updated' if existing else 'created'] += 1
                    if post:
                        move.action_post()
                        summary['posted'] += 1
                        self._fc_reconcile_payment(move, inv)
            except Exception as e:  # noqa: BLE001 - per-invoice isolation
                _logger.exception("Ledger ingest: invoice %s failed", nc_id)
                summary['failed'].append({'id': nc_id, 'error': '%s: %s' % (type(e).__name__, e)})
        return summary

    @api.model
    def _fc_reconcile_payment(self, move, inv):
        """Placeholder until Task 5; defined so post=True doesn't AttributeError."""
        return False
  • Step 4: run → PASS. (If tax computes to 13.00 only when the company/fiscal position allows it, read the tax setup on trial; if amount_tax ≠ 13.00, the chosen tax is wrong — fix _fc_tax_for, never weaken the assertion.)

  • Step 5: commitfeat(billing): ingest NexaCloud invoices -> draft account.move (idempotent)


Task 5: Reconcile Stripe payments (paid invoices show paid)

Read reference first: confirm the payment-register flow on trial:

ssh pve-worker1 "qm guest exec 316 -- bash -lc 'docker exec odoo-trial-app bash -lc \"grep -nE \\\"_create_payments|def action_create_payments\\\" /mnt/enterprise-addons/account/wizard/account_payment_register.py | head\"'"

Files: modify wizards/invoice_ledger.py, tests/test_invoice_ledger.py.

  • Step 1: failing test (append):
    def test_paid_invoice_is_reconciled_and_shows_paid(self):
        data = _inv_fixture()
        data[0].update({'status': 'paid', 'amount_paid': 113.0, 'paid_at': '2026-05-02'})
        self.W._ingest_invoices(data, post=True)
        mv = self.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
        self.assertEqual(mv.state, 'posted')
        self.assertIn(mv.payment_state, ('paid', 'in_payment'))

(Add this inside TestLedgerIngest.)

  • Step 2: run → FAIL (payment not reconciled).

  • Step 3: implement _fc_reconcile_payment + a journal helper (replace the placeholder):

    @api.model
    def _fc_stripe_journal(self):
        Journal = self.env['account.journal']
        j = Journal.search([('code', '=', 'NCSTR')], limit=1)
        if not j:
            j = Journal.create({'name': 'NexaCloud Stripe', 'code': 'NCSTR', 'type': 'bank'})
        return j

    @api.model
    def _fc_reconcile_payment(self, move, inv):
        paid = float(inv.get('amount_paid') or 0.0)
        if (inv.get('status') != 'paid' and paid <= 0) or move.state != 'posted':
            return False
        reg = self.env['account.payment.register'].with_context(
            active_model='account.move', active_ids=move.ids).create({
                'journal_id': self._fc_stripe_journal().id,
                'payment_date': inv.get('paid_at') or move.invoice_date or fields.Date.today(),
                'amount': paid or move.amount_total,
            })
        reg._create_payments()
        return True
  • Step 4: run → PASS. (If payment_state is in_payment rather than paid, that's expected when the bank journal isn't reconciled to a statement — accept both, as the assertion does.)

  • Step 5: commitfeat(billing): reconcile Stripe payments so ingested invoices show paid


Task 6: Reader + wizard actions + bulk-post + cron

Files: modify wizards/invoice_ledger.py, views/invoice_ledger_views.xml, tests/test_invoice_ledger.py.

  • Step 1: failing test for bulk-post + DSN guard (append):
    def test_post_ingested_posts_drafts(self):
        self.W._ingest_invoices(_inv_fixture(), post=False)
        n = self.W._post_ingested()
        mv = self.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
        self.assertEqual(mv.state, 'posted')
        self.assertGreaterEqual(n, 1)

    def test_read_invoices_guards_missing_dsn(self):
        from odoo.exceptions import UserError
        self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
        with self.assertRaises(UserError):
            self.W._read_nexacloud_invoices()
  • Step 2: run → FAIL.

  • Step 3: implement _post_ingested, _read_nexacloud_invoices, action_run, and a cron entry:

    @api.model
    def _post_ingested(self):
        moves = self.env['account.move'].search([
            ('x_fc_nexacloud_invoice_id', '!=', False),
            ('state', '=', 'draft'), ('move_type', '=', 'out_invoice')])
        posted = 0
        for mv in moves:
            try:
                with self.env.cr.savepoint():
                    mv.action_post()
                    posted += 1
            except Exception as e:  # noqa: BLE001
                _logger.exception("Ledger post: move %s failed", mv.id)
        return posted

    def _read_nexacloud_invoices(self, since=None):
        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 (fusion_billing.nexacloud_dsn).")
        try:
            conn = psycopg2.connect(dsn)
        except Exception as e:  # noqa: BLE001
            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)
            where = "WHERE i.created_at >= %(since)s" if since else ""
            cur.execute(
                "SELECT i.id, i.stripe_invoice_id, i.invoice_number, i.user_id AS user_external_id, "
                "u.full_name AS partner_name, COALESCE(u.billing_email,u.email) AS partner_email, "
                "i.created_at AS invoice_date, i.currency, i.status, i.subtotal, i.tax, "
                "i.amount_paid, i.paid_at "
                "FROM invoices i JOIN users u ON u.id = i.user_id " + where +
                " ORDER BY i.created_at", {'since': since})
            invoices = {str(r['id']): dict(r, items=[]) for r in cur.fetchall()}
            cur.execute(
                "SELECT ii.invoice_id, ii.description, ii.quantity, ii.unit_price, ii.amount "
                "FROM invoice_items ii WHERE ii.invoice_id = ANY(%(ids)s)",
                {'ids': list(invoices.keys())})
            for r in cur.fetchall():
                inv = invoices.get(str(r['invoice_id']))
                if inv:
                    inv['items'].append({'description': r['description'], 'quantity': r['quantity'],
                                         'unit_price': r['unit_price'], 'amount': r['amount']})
            for inv in invoices.values():
                inv['id'] = str(inv['id'])
                inv['user_external_id'] = str(inv['user_external_id'])
            return list(invoices.values())
        except psycopg2.Error as e:
            raise UserError("Failed reading NexaCloud invoices — schema may have changed:\n%s" % e)
        finally:
            conn.close()

    def action_run(self):
        self.ensure_one()
        data = self._read_nexacloud_invoices()
        if self.dry_run:
            class _Rollback(Exception):
                pass
            res = {}
            try:
                with self.env.cr.savepoint():
                    res.update(self._ingest_invoices(data, post=False))
                    raise _Rollback()
            except _Rollback:
                pass
            res['dry_run'] = True
        else:
            res = self._ingest_invoices(data, post=self.auto_post)
        self.result_summary = json.dumps(res, indent=2, default=str)
        if res.get('failed'):
            _logger.error("Ledger ingest: %s failed: %s", len(res['failed']), res['failed'])
        return {"type": "ir.actions.act_window", "res_model": self._name,
                "res_id": self.id, "view_mode": "form", "target": "new"}

Add a daily cron to views/invoice_ledger_views.xml:

    <record id="cron_fc_invoice_ledger" model="ir.cron">
        <field name="name">Fusion Billing: Ingest NexaCloud invoices (daily)</field>
        <field name="model_id" ref="model_fusion_billing_invoice_ledger_wizard"/>
        <field name="state">code</field>
        <field name="code">model.create({'dry_run': False, 'auto_post': True})._cron_ingest_recent()</field>
        <field name="interval_number">1</field>
        <field name="interval_type">days</field>
        <field name="active">False</field>
    </record>

And _cron_ingest_recent (ingest invoices from the last 2 days, idempotent):

    def _cron_ingest_recent(self):
        from datetime import timedelta
        since = fields.Datetime.to_string(fields.Datetime.now() - timedelta(days=2))
        return self._ingest_invoices(self._read_nexacloud_invoices(since=since), post=True)

(Cron ships active=False — enabled only after the backfill is reviewed.)

  • Step 4: run → PASS.

  • Step 5: commitfeat(billing): invoice-ledger reader, wizard actions, bulk-post, daily cron


Task 7: Prune obsolete metered shadow data

Files: modify wizards/invoice_ledger.py, tests/test_invoice_ledger.py.

  • Step 1: failing test (append):
    def test_prune_shadow_removes_shadow_subs_only(self):
        # a shadow sub + a normal order
        p = self.env['res.partner'].sudo().create({'name': 'X'})
        shadow = self.env['sale.order'].sudo().create({'partner_id': p.id, 'x_fc_shadow': True})
        n = self.W._fc_prune_metered_shadow()
        self.assertFalse(shadow.exists())
        self.assertGreaterEqual(n.get('subscriptions', 0), 1)
  • Step 2: run → FAIL.

  • Step 3: implement:

    @api.model
    def _fc_prune_metered_shadow(self):
        """Delete the superseded metered shadow data (shadow sale.orders, NC-* products,
        NexaCloud charges, reconciliation rows). Reversible only by re-import."""
        counts = {}
        subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)])
        counts['subscriptions'] = len(subs)
        subs.unlink()
        prods = self.env['product.product'].search([('default_code', '=like', 'NC-%')])
        counts['products'] = len(prods)
        prods.unlink()
        ch = self.env['fusion.billing.charge'].search([])
        counts['charges'] = len(ch)
        ch.unlink()
        rec = self.env['fusion.billing.reconciliation'].search([])
        counts['reconciliations'] = len(rec)
        rec.unlink()
        return counts
  • Step 4: run → PASS. (If a product can't unlink due to references, archive instead — read the error and adjust.)

  • Step 5: commitfeat(billing): prune obsolete metered shadow data helper


Task 8: Full suite + static checks

  • bash scripts/fcb_test_on_trial.shFCB_EXIT=0.
  • grep -rn "_sql_constraints" fusion_centralize_billing/ || echo clean → clean.
  • grep -rnE "sale\.subscription[^.]" fusion_centralize_billing/ | grep -v "sale.subscription.plan" → only docstring.
  • commit any fixes.

Done = invoice ledger ready to run

Then (separate, gated, NOT in this plan): on nexamain — prune shadow data, dry-run the full backfill (review the per-family $ summary + unmatched "Other" lines), ingest as draft, you review a sample, bulk-post, enable the daily cron.