diff --git a/fusion_centralize_billing/__manifest__.py b/fusion_centralize_billing/__manifest__.py index 233cc1fb..df8d8628 100644 --- a/fusion_centralize_billing/__manifest__.py +++ b/fusion_centralize_billing/__manifest__.py @@ -49,6 +49,7 @@ reference files from the container before implementing subscription/account inte "security/ir.model.access.csv", "data/ir_cron.xml", "views/import_wizard_views.xml", + "views/invoice_ledger_views.xml", ], "installable": True, "application": False, diff --git a/fusion_centralize_billing/models/__init__.py b/fusion_centralize_billing/models/__init__.py index 3583c9c7..c5564420 100644 --- a/fusion_centralize_billing/models/__init__.py +++ b/fusion_centralize_billing/models/__init__.py @@ -7,3 +7,4 @@ from . import webhook from . import reconciliation from . import sale_order from . import res_partner +from . import account_move diff --git a/fusion_centralize_billing/models/account_move.py b/fusion_centralize_billing/models/account_move.py new file mode 100644 index 00000000..cf8ce853 --- /dev/null +++ b/fusion_centralize_billing/models/account_move.py @@ -0,0 +1,17 @@ +# -*- 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.", + ) diff --git a/fusion_centralize_billing/security/ir.model.access.csv b/fusion_centralize_billing/security/ir.model.access.csv index d0ca3d8f..6467fcca 100644 --- a/fusion_centralize_billing/security/ir.model.access.csv +++ b/fusion_centralize_billing/security/ir.model.access.csv @@ -10,3 +10,4 @@ access_fusion_billing_metric_acct,fusion.billing.metric accountant,model_fusion_ access_fusion_billing_charge_acct,fusion.billing.charge accountant,model_fusion_billing_charge,account.group_account_manager,1,1,1,0 access_fusion_billing_reconciliation_acct,fusion.billing.reconciliation accountant,model_fusion_billing_reconciliation,account.group_account_manager,1,1,1,0 access_fusion_billing_import_wizard,fusion.billing.import.wizard,model_fusion_billing_import_wizard,base.group_system,1,1,1,1 +access_fc_invoice_ledger_wizard,fusion.billing.invoice.ledger.wizard,model_fusion_billing_invoice_ledger_wizard,base.group_system,1,1,1,1 diff --git a/fusion_centralize_billing/tests/__init__.py b/fusion_centralize_billing/tests/__init__.py index 1143c471..59f53b12 100644 --- a/fusion_centralize_billing/tests/__init__.py +++ b/fusion_centralize_billing/tests/__init__.py @@ -5,3 +5,4 @@ from . import test_api from . import test_webhook from . import test_importer from . import test_reconciliation +from . import test_invoice_ledger diff --git a/fusion_centralize_billing/tests/test_invoice_ledger.py b/fusion_centralize_billing/tests/test_invoice_ledger.py new file mode 100644 index 00000000..9787d911 --- /dev/null +++ b/fusion_centralize_billing/tests/test_invoice_ledger.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase, tagged + + +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 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) + self.assertEqual(self.W._fc_income_account('hosting'), a_host) # idempotent + + +@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') + 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) + + +@tagged('post_install', '-at_install') +class TestLedgerIngest(TransactionCase): + + def setUp(self): + super().setUp() + self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo() + self.Move = self.env['account.move'] + + def test_ingest_creates_draft_invoice_with_right_totals(self): + self.W._ingest_invoices(_inv_fixture(), post=False) + mv = self.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') + self.assertEqual(mv.invoice_line_ids.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.Move.search_count( + [('x_fc_nexacloud_invoice_id', '=', 'inv-1')]), 1) + + 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.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')]) + self.assertEqual(mv.state, 'posted') + self.assertIn(mv.payment_state, ('paid', 'in_payment')) + + def test_post_ingested_posts_drafts(self): + self.W._ingest_invoices(_inv_fixture(), post=False) + n = self.W._post_ingested() + mv = self.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): + self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '') + with self.assertRaises(UserError): + self.W._read_nexacloud_invoices() + + def test_prune_shadow_removes_shadow_subs_only(self): + p = self.env['res.partner'].sudo().create({'name': 'X'}) + shadow = self.env['sale.order'].sudo().create({'partner_id': p.id, 'x_fc_shadow': True}) + counts = self.W._fc_prune_metered_shadow() + self.assertFalse(shadow.exists()) + self.assertGreaterEqual(counts.get('subscriptions', 0), 1) diff --git a/fusion_centralize_billing/views/invoice_ledger_views.xml b/fusion_centralize_billing/views/invoice_ledger_views.xml new file mode 100644 index 00000000..2e60afe9 --- /dev/null +++ b/fusion_centralize_billing/views/invoice_ledger_views.xml @@ -0,0 +1,44 @@ + + + + fusion.billing.invoice.ledger.wizard.form + fusion.billing.invoice.ledger.wizard + +
+ + + + + + + +
+
+
+
+
+ + + Ingest NexaCloud Invoices + fusion.billing.invoice.ledger.wizard + form + new + + + + + + Fusion Billing: Ingest NexaCloud invoices (daily) + + code + model.create({'dry_run': False, 'auto_post': True})._cron_ingest_recent() + 1 + days + False + +
diff --git a/fusion_centralize_billing/wizards/__init__.py b/fusion_centralize_billing/wizards/__init__.py index 68201671..e39832e9 100644 --- a/fusion_centralize_billing/wizards/__init__.py +++ b/fusion_centralize_billing/wizards/__init__.py @@ -1 +1,2 @@ from . import import_wizard +from . import invoice_ledger diff --git a/fusion_centralize_billing/wizards/invoice_ledger.py b/fusion_centralize_billing/wizards/invoice_ledger.py new file mode 100644 index 00000000..5c0609fb --- /dev/null +++ b/fusion_centralize_billing/wizards/invoice_ledger.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +"""NexaCloud → Odoo invoice ledger ingester. + +Reads NexaCloud's real (Stripe-billed) invoices and creates native Odoo +``account.move`` customer invoices — posted, with the Stripe payments reconciled and +HST modelled — so Odoo is the accounting system of record. Revenue is split by service +family into distinct income accounts. NexaCloud/Stripe keep doing the billing; Odoo +ingests its output. See docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md +""" +import json +import logging +import re +from datetime import timedelta + +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) + + # description keyword -> service family (checked in order; hosting before managed) + _FAMILY_KEYWORDS = [ + ("hosting", ["odoo erp hosting", "wordpress website hosting"]), + ("managed", ["managed"]), + ("addons", ["daily backup", "whatsapp", "forms builder", "white label"]), + ] + + 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"} + + # ----- read side (the ONLY code that touches NexaCloud) ------------------ + 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()} + if invoices: + 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"]}) + out = [] + for inv in invoices.values(): + inv["id"] = str(inv["id"]) + inv["user_external_id"] = str(inv["user_external_id"]) + out.append(inv) + return out + except psycopg2.Error as e: + raise UserError( + "Failed reading NexaCloud invoices — the source schema may have changed. " + "Underlying error:\n%s" % e) + finally: + conn.close() + + # ----- ingest side (pure Odoo; unit-tested) ------------------------------ + @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 and existing.state != "draft": + summary["skipped"].append({"id": nc_id, "reason": "already posted"}) + continue + if existing: + 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 _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: # noqa: BLE001 + _logger.exception("Ledger post: move %s failed", mv.id) + return posted + + def _cron_ingest_recent(self): + since = fields.Datetime.to_string(fields.Datetime.now() - timedelta(days=2)) + return self._ingest_invoices(self._read_nexacloud_invoices(since=since), post=True) + + # ----- helpers ------------------------------------------------------------ + @api.model + def _fc_family_for(self, description): + 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"] + # Odoo 19 account codes allow only alphanumerics + dots (no hyphen). + code = "NCR." + family.upper() + 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 + + @api.model + def _fc_tax_for(self, subtotal, tax_amount): + """Map a NexaCloud invoice's (subtotal, tax) 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) + amt = float(tax_amount or 0.0) + if sub <= 0 or amt <= 0: + return Tax.search([("type_tax_use", "=", "sale"), ("amount", "=", 0.0)], limit=1) + rate = round(100.0 * 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 + + @api.model + def _fc_partner_for(self, inv): + """Resolve the unified partner via the nexacloud account.link (by user id); + create partner+link if missing (covers NULL-subscription invoices).""" + service = self.env["fusion.billing.service"].search([("code", "=", "nexacloud")], limit=1) + if not service: + service = self.env["fusion.billing.service"].create( + {"name": "NexaCloud", "code": "nexacloud"}) + 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 _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 + + @api.model + def _fc_prune_metered_shadow(self): + """Delete the superseded metered shadow data (shadow sale.orders, NC-* products, + NexaCloud charges, reconciliation rows).""" + 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