feat(billing): NexaCloud invoice ledger — ingest invoices to account.move

Odoo becomes the accounting SoR by ingesting NexaCloud's real Stripe
invoices (read-only via the existing DSN) into native account.move
customer invoices: per-service-family income accounts, tax derived to
match the source invoice.tax, Stripe payments reconciled via
account.payment.register (invoice shows paid), idempotent on
x_fc_nexacloud_invoice_id, draft-first with bulk-post + a daily cron
(inactive). Plus a prune helper for the now-obsolete metered shadow data.
73 tests green on odoo-trial. Account codes use dots (Odoo 19 rejects '-').
This commit is contained in:
gsinghpal
2026-05-27 16:50:31 -04:00
parent f6518b4d7e
commit 72d3130c88
9 changed files with 456 additions and 0 deletions

View File

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

View File

@@ -7,3 +7,4 @@ from . import webhook
from . import reconciliation
from . import sale_order
from . import res_partner
from . import account_move

View File

@@ -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.",
)

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
10 access_fusion_billing_charge_acct fusion.billing.charge accountant model_fusion_billing_charge account.group_account_manager 1 1 1 0
11 access_fusion_billing_reconciliation_acct fusion.billing.reconciliation accountant model_fusion_billing_reconciliation account.group_account_manager 1 1 1 0
12 access_fusion_billing_import_wizard fusion.billing.import.wizard model_fusion_billing_import_wizard base.group_system 1 1 1 1
13 access_fc_invoice_ledger_wizard fusion.billing.invoice.ledger.wizard model_fusion_billing_invoice_ledger_wizard base.group_system 1 1 1 1

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
<?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"/>
<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>
</odoo>

View File

@@ -1 +1,2 @@
from . import import_wizard
from . import invoice_ledger

View File

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