fix(billing): ledger live-run fixes — UUID cast, UTF-8, reconciling line
Surfaced by the nexamain dry-run against real data:
- reader: cast invoice_items.invoice_id::text (uuid = text[] mismatch).
- readers: set_client_encoding('UTF8') — invoice descriptions contain "×".
- ingest: add a balancing line when invoice.subtotal != sum(items). 9 paid
base-plan invoices store the charge in subtotal with NO invoice_items, so
itemized ingestion under-recorded revenue by ~$1,143 (37%); the reconciling
line makes the Odoo invoice total match what Stripe billed.
74 tests green on odoo-trial.
This commit is contained in:
@@ -107,6 +107,19 @@ class TestLedgerIngest(TransactionCase):
|
|||||||
with self.assertRaises(UserError):
|
with self.assertRaises(UserError):
|
||||||
self.W._read_nexacloud_invoices()
|
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):
|
def test_prune_shadow_removes_shadow_subs_only(self):
|
||||||
p = self.env['res.partner'].sudo().create({'name': 'X'})
|
p = self.env['res.partner'].sudo().create({'name': 'X'})
|
||||||
shadow = self.env['sale.order'].sudo().create({'partner_id': p.id, 'x_fc_shadow': True})
|
shadow = self.env['sale.order'].sudo().create({'partner_id': p.id, 'x_fc_shadow': True})
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ class FusionBillingImportWizard(models.TransientModel):
|
|||||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||||
try:
|
try:
|
||||||
conn.set_session(readonly=True)
|
conn.set_session(readonly=True)
|
||||||
|
conn.set_client_encoding('UTF8')
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
data = {}
|
data = {}
|
||||||
cur.execute(
|
cur.execute(
|
||||||
@@ -161,6 +162,7 @@ class FusionBillingImportWizard(models.TransientModel):
|
|||||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||||
try:
|
try:
|
||||||
conn.set_session(readonly=True)
|
conn.set_session(readonly=True)
|
||||||
|
conn.set_client_encoding('UTF8')
|
||||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT subscription_id::text AS sub, "
|
"SELECT subscription_id::text AS sub, "
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
|||||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||||
try:
|
try:
|
||||||
conn.set_session(readonly=True)
|
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)
|
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
where = "WHERE i.created_at >= %(since)s" if since else ""
|
where = "WHERE i.created_at >= %(since)s" if since else ""
|
||||||
cur.execute(
|
cur.execute(
|
||||||
@@ -87,7 +88,7 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
|||||||
if invoices:
|
if invoices:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT ii.invoice_id, ii.description, ii.quantity, ii.unit_price, ii.amount "
|
"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())})
|
{"ids": list(invoices.keys())})
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
inv = invoices.get(str(r["invoice_id"]))
|
inv = invoices.get(str(r["invoice_id"]))
|
||||||
@@ -150,6 +151,22 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
|||||||
"account_id": self._fc_income_account(fam).id,
|
"account_id": self._fc_income_account(fam).id,
|
||||||
"tax_ids": [(6, 0, tax.ids)] if tax else [(5, 0, 0)],
|
"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})
|
move.write({"invoice_line_ids": line_vals})
|
||||||
summary["updated" if existing else "created"] += 1
|
summary["updated" if existing else "created"] += 1
|
||||||
if post:
|
if post:
|
||||||
|
|||||||
Reference in New Issue
Block a user