diff --git a/docs/superpowers/plans/2026-05-27-nexacloud-invoice-ledger.md b/docs/superpowers/plans/2026-05-27-nexacloud-invoice-ledger.md
new file mode 100644
index 00000000..e6225a7b
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-27-nexacloud-invoice-ledger.md
@@ -0,0 +1,637 @@
+# 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, ~1–2 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 inherit** — `models/account_move.py`:
+```python
+# -*- 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 skeleton** — `wizards/invoice_ledger.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__)
+
+
+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 + menu** — `views/invoice_ledger_views.xml`:
+```xml
+
+
+
+ fusion.billing.invoice.ledger.wizard.form
+ fusion.billing.invoice.ledger.wizard
+
+
+
+
+
+ Ingest NexaCloud Invoices
+ fusion.billing.invoice.ledger.wizard
+ form
+ new
+
+
+
+```
+
+- [ ] **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 upgrade** — `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0` (existing tests pass; new model/fields/view load).
+
+- [ ] **Step 6: commit** — `feat(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 test** — `tests/test_invoice_ledger.py`:
+```python
+# -*- 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`:
+```python
+ _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: commit** — `feat(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):
+```python
+@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**:
+```python
+ @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: commit** — `feat(billing): ledger tax derivation matching source invoice tax`
+
+---
+
+## Task 4: Ingest invoices → draft account.move (idempotent)
+
+**Read reference first:**
+```bash
+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:
+```python
+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`:
+```python
+ @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: commit** — `feat(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:
+```bash
+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):
+```python
+ 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):
+```python
+ @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: commit** — `feat(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):
+```python
+ 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:
+```python
+ @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`:
+```xml
+
+ Fusion Billing: Ingest NexaCloud invoices (daily)
+
+ code
+ model.create({'dry_run': False, 'auto_post': True})._cron_ingest_recent()
+ 1
+ days
+ False
+
+```
+And `_cron_ingest_recent` (ingest invoices from the last 2 days, idempotent):
+```python
+ 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: commit** — `feat(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):
+```python
+ 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**:
+```python
+ @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: commit** — `feat(billing): prune obsolete metered shadow data helper`
+
+---
+
+## Task 8: Full suite + static checks
+
+- [ ] `bash scripts/fcb_test_on_trial.sh` → `FCB_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.