diff --git a/fusion_centralize_billing/tests/test_invoice_ledger.py b/fusion_centralize_billing/tests/test_invoice_ledger.py index 9787d911..57c2ba4c 100644 --- a/fusion_centralize_billing/tests/test_invoice_ledger.py +++ b/fusion_centralize_billing/tests/test_invoice_ledger.py @@ -107,6 +107,19 @@ class TestLedgerIngest(TransactionCase): with self.assertRaises(UserError): self.W._read_nexacloud_invoices() + def test_unitemized_subtotal_gets_reconciling_line(self): + data = [{ + 'id': 'inv-base', 'stripe_invoice_id': 'in_base', 'invoice_number': 'NEX-BASE', + 'user_external_id': 'u-2', 'partner_name': 'Globex', 'partner_email': 'ops@globex.test', + 'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'open', + 'subtotal': 200.0, 'tax': 0.0, 'amount_paid': 0.0, 'paid_at': None, + 'items': [], # base plan billed via Stripe only — no line items + }] + self.W._ingest_invoices(data, post=False) + mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-base')]) + self.assertAlmostEqual(mv.amount_untaxed, 200.0, places=2) # captured via reconciling line + self.assertTrue(any('base/unitemized' in (l.name or '') for l in mv.invoice_line_ids)) + 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}) diff --git a/fusion_centralize_billing/wizards/import_wizard.py b/fusion_centralize_billing/wizards/import_wizard.py index 485780b2..5db604dc 100644 --- a/fusion_centralize_billing/wizards/import_wizard.py +++ b/fusion_centralize_billing/wizards/import_wizard.py @@ -116,6 +116,7 @@ class FusionBillingImportWizard(models.TransientModel): raise UserError("Could not connect to the NexaCloud database: %s" % e) try: conn.set_session(readonly=True) + conn.set_client_encoding('UTF8') cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) data = {} cur.execute( @@ -161,6 +162,7 @@ class FusionBillingImportWizard(models.TransientModel): raise UserError("Could not connect to the NexaCloud database: %s" % e) try: conn.set_session(readonly=True) + conn.set_client_encoding('UTF8') cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute( "SELECT subscription_id::text AS sub, " diff --git a/fusion_centralize_billing/wizards/invoice_ledger.py b/fusion_centralize_billing/wizards/invoice_ledger.py index 5c0609fb..8249e778 100644 --- a/fusion_centralize_billing/wizards/invoice_ledger.py +++ b/fusion_centralize_billing/wizards/invoice_ledger.py @@ -73,6 +73,7 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel): raise UserError("Could not connect to the NexaCloud database: %s" % e) try: conn.set_session(readonly=True) + conn.set_client_encoding('UTF8') # invoice descriptions contain non-ASCII (e.g. "×") cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) where = "WHERE i.created_at >= %(since)s" if since else "" cur.execute( @@ -87,7 +88,7 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel): 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)", + "FROM invoice_items ii WHERE ii.invoice_id::text = ANY(%(ids)s)", {"ids": list(invoices.keys())}) for r in cur.fetchall(): inv = invoices.get(str(r["invoice_id"])) @@ -150,6 +151,22 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel): "account_id": self._fc_income_account(fam).id, "tax_ids": [(6, 0, tax.ids)] if tax else [(5, 0, 0)], })) + # Many NexaCloud base-plan invoices store the charge in `subtotal` with + # NO invoice_items. Add a balancing line for any gap so the Odoo invoice + # total matches what Stripe actually billed (captures un-itemized revenue + # and absorbs proration credits where items exceed subtotal). + items_total = round(sum(float(it.get("amount") or 0.0) + for it in inv.get("items", [])), 2) + gap = round(float(inv.get("subtotal") or 0.0) - items_total, 2) + if abs(gap) > 0.01: + summary["by_family"]["base"] = round( + summary["by_family"].get("base", 0.0) + gap, 2) + line_vals.append((0, 0, { + "name": "NexaCloud base/unitemized charge", + "quantity": 1.0, "price_unit": gap, + "account_id": self._fc_income_account("base").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: