From e36318f7a5403853ea1df1e93defd0ee1b5667a0 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 18:37:36 -0400 Subject: [PATCH] feat(billing): Stripe/Lago-verified go-forward sync + activate daily cron The NexaCloud->Odoo ledger now verifies every new invoice against its SOURCE billing system before posting, instead of trusting NexaCloud's unreliable created_at/status/paid_at: - _fc_verify routes by stripe_invoice_id prefix (in_ -> Stripe REST, lago: -> Lago REST) and returns source-truth {invoice_date, void, draft, paid, paid_at, amount_paid}, or None when it can't be determined/reached (left for the next run). - _ingest_invoices(post=True, verified=...) uses the source invoice date (and accounting date), and reconciles a payment ONLY when the source confirms paid. - _cron_sync_verified posts only finalized invoices; skips void + draft, logs unverified for retry. Replaces the old _cron_ingest_recent. Cron cron_fc_invoice_ledger is enabled daily on nexamain. First live run: 23 already-posted, 1 void + 2 Stripe drafts + 5 zero-amount all skipped, 0 new posted, ledger intact at $3,403.46. Tests: routing/guards (no network), verified date+reconcile, and the cron's void/draft/unverified filtering (sources patched). FCB_EXIT=0 on odoo-trial. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...6-05-27-nexacloud-invoice-ledger-design.md | 27 ++- fusion_centralize_billing/__manifest__.py | 2 +- fusion_centralize_billing/data/ir_cron.xml | 13 ++ .../tests/test_invoice_ledger.py | 100 ++++++++++ .../wizards/invoice_ledger.py | 176 ++++++++++++++++-- 5 files changed, 299 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md b/docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md index 52343a47..ff0228b3 100644 --- a/docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md +++ b/docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md @@ -134,8 +134,25 @@ Income-account codes come from the COA (`nexa_coa_setup`); confirm/create at imp `payment_status=succeeded` via the Lago API (`billing.nexasystems.ca/api/api/v1`, key in Fusion-Chat; Lago host 192.168.1.117, double-hop ssh via supabase-prod). Partner names came from the NexaCloud `company` field (not the user's full_name). -- **GO-FORWARD CAVEAT — do NOT enable the daily cron yet.** `_ingest_invoices` / - `_post_and_reconcile_paid` still date + gauge payment from NexaCloud's own fields, which - are unreliable for new invoices too. Before automating, the ledger must verify each new - invoice's date + paid status against Stripe (and Lago, until decommissioned) — i.e. fold - the verification used in this backfill into the ingest path. +- **GO-FORWARD: verified sync is LIVE (2026-05-27).** The verification used in the backfill + is now folded into the ingest path, and the daily cron is enabled: + - `_fc_verify(inv)` routes each invoice to its source by `stripe_invoice_id` prefix + (`in_` → Stripe REST `GET /v1/invoices/{id}`; `lago:` → Lago REST) and returns + `{invoice_date, void, draft, paid, paid_at, amount_paid}` taken from the SOURCE — or + `None` if it can't be determined/reached. Credentials live in `ir.config_parameter`: + `fusion_billing.stripe_api_key` (set, live), `fusion_billing.lago_api_url` / + `fusion_billing.lago_api_key` (optional; unset — no new Lago invoices expected). + - `_cron_sync_verified()` reads all NexaCloud invoices, skips ones already posted, then + for the rest: skips **void** and **draft** (not finalized at source), logs **unverified** + for retry next run, and ingests the rest with `_ingest_invoices(post=True, verified=…)` + so the move uses the source invoice_date (accounting date too) and a payment is + reconciled ONLY when the source confirms paid. Never acts on NexaCloud's raw fields. + - Cron `cron_fc_invoice_ledger` on nexamain: **active**, daily at 06:00 UTC. (A stale + pre-existing copy of this record still called the removed `_cron_ingest_recent`; because + the data file is `noupdate="1"` the upgrade didn't rewrite it, so its server-action code + + name were corrected once via SQL. Fresh installs get the right definition from the XML.) + - First live run (2026-05-27): 23 already-posted, 1 void + 2 Stripe drafts + 5 genuine + $0 invoices all correctly skipped, **0 new posted**, ledger intact at $3,403.46. + - Verification helpers are unit-tested without network (routing short-circuits when no + credentials are set; the cron is exercised with `_read_nexacloud_invoices` / `_fc_verify` + patched). Full suite green on odoo-trial (`FCB_EXIT=0`). diff --git a/fusion_centralize_billing/__manifest__.py b/fusion_centralize_billing/__manifest__.py index df8d8628..8ceafa3a 100644 --- a/fusion_centralize_billing/__manifest__.py +++ b/fusion_centralize_billing/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { "name": "Fusion Centralized Billing", - "version": "19.0.1.0.0", + "version": "19.0.1.1.0", "category": "Accounting/Subscriptions", "summary": "Centralized billing engine for all NexaSystems services — metered usage, " "per-app billing API, and outbound webhooks on top of Odoo Enterprise subscriptions.", diff --git a/fusion_centralize_billing/data/ir_cron.xml b/fusion_centralize_billing/data/ir_cron.xml index 97511c84..510f3ffa 100644 --- a/fusion_centralize_billing/data/ir_cron.xml +++ b/fusion_centralize_billing/data/ir_cron.xml @@ -19,4 +19,17 @@ minutes True + + + + Fusion Billing: Sync NexaCloud invoices (Stripe/Lago verified) + + code + model._cron_sync_verified() + 1 + days + False + diff --git a/fusion_centralize_billing/tests/test_invoice_ledger.py b/fusion_centralize_billing/tests/test_invoice_ledger.py index 14f59d2d..c2f4fe29 100644 --- a/fusion_centralize_billing/tests/test_invoice_ledger.py +++ b/fusion_centralize_billing/tests/test_invoice_ledger.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 +from unittest.mock import patch + from odoo.exceptions import UserError from odoo.tests.common import TransactionCase, tagged @@ -161,3 +163,101 @@ class TestLedgerIngest(TransactionCase): counts = self.W._fc_prune_metered_shadow() self.assertFalse(shadow.exists()) self.assertGreaterEqual(counts.get('subscriptions', 0), 1) + + +@tagged('post_install', '-at_install') +class TestLedgerVerifiedSync(TransactionCase): + """The go-forward path: invoice date + paid status come from the SOURCE billing + system (Stripe/Lago), never NexaCloud's own fields. HTTP is never hit in tests — + routing short-circuits when no API credentials are configured, and the cron is + exercised with _read_nexacloud_invoices / _fc_verify patched out.""" + + def setUp(self): + super().setUp() + self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo() + self.Move = self.env['account.move'] + ICP = self.env['ir.config_parameter'].sudo() + # ensure no real credentials -> verify helpers short-circuit, never touch network + ICP.set_param('fusion_billing.stripe_api_key', '') + ICP.set_param('fusion_billing.lago_api_url', '') + ICP.set_param('fusion_billing.lago_api_key', '') + + def test_ts_to_date_is_utc_and_none_safe(self): + self.assertEqual(self.W._fc_ts_to_date(0), '1970-01-01') + self.assertEqual(self.W._fc_ts_to_date(86400), '1970-01-02') + self.assertIsNone(self.W._fc_ts_to_date(None)) + + def test_verify_routes_and_guards_without_network(self): + # Stripe id with no key, Lago id with no config, and an unroutable id all -> None + self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': 'in_abc'})) + self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': 'lago:xyz'})) + self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': 'mystery'})) + self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': None})) + + def test_verified_paid_uses_source_date_and_reconciles(self): + v = {'inv-1': {'invoice_date': '2026-02-10', 'void': False, 'paid': True, + 'paid_at': '2026-02-12', 'amount_paid': 113.0}} + self.W._ingest_invoices(_inv_fixture(), post=True, verified=v) + mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')]) + self.assertEqual(mv.state, 'posted') + self.assertEqual(str(mv.invoice_date), '2026-02-10') # source date, not NexaCloud's + self.assertEqual(str(mv.date), str(mv.invoice_date)) # accounting date tracks it + self.assertIn(mv.payment_state, ('paid', 'in_payment')) + + def test_verified_unpaid_posts_but_is_not_reconciled(self): + v = {'inv-1': {'invoice_date': '2026-04-01', 'void': False, 'paid': False, + 'paid_at': None, 'amount_paid': 0.0}} + self.W._ingest_invoices(_inv_fixture(), post=True, verified=v) + mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')]) + self.assertEqual(mv.state, 'posted') + self.assertEqual(str(mv.invoice_date), '2026-04-01') + self.assertEqual(mv.payment_state, 'not_paid') + + def test_cron_skips_void_draft_unverified_posts_only_finalized(self): + base = _inv_fixture()[0] + fixtures = [ + dict(base, id='inv-paid', invoice_number='NEX-P', stripe_invoice_id='in_paid'), + dict(base, id='inv-void', invoice_number='NEX-V', stripe_invoice_id='in_void'), + dict(base, id='inv-draft', invoice_number=None, stripe_invoice_id='in_draft'), + dict(base, id='inv-unver', invoice_number='NEX-U', stripe_invoice_id='weird'), + ] + verdicts = { + 'inv-paid': {'invoice_date': '2026-03-01', 'void': False, 'draft': False, + 'paid': True, 'paid_at': '2026-03-02', 'amount_paid': 113.0}, + 'inv-void': {'invoice_date': '2026-03-01', 'void': True, 'draft': False, + 'paid': False, 'paid_at': None, 'amount_paid': 0.0}, + 'inv-draft': {'invoice_date': '2026-03-01', 'void': False, 'draft': True, + 'paid': False, 'paid_at': None, 'amount_paid': 0.0}, + } + cls = type(self.W) + with patch.object(cls, '_read_nexacloud_invoices', return_value=fixtures), \ + patch.object(cls, '_fc_verify', + side_effect=lambda inv: verdicts.get(str(inv.get('id')))): + summary = self.W._cron_sync_verified() + self.assertEqual(summary['skipped_void'], 1) + self.assertEqual(summary['skipped_draft'], 1) + self.assertEqual(summary['unverified'], ['inv-unver']) + self.assertEqual(summary['posted'], 1) + self.assertEqual(summary['reconciled'], 1) + paid = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-paid')]) + self.assertEqual(paid.state, 'posted') + self.assertEqual(str(paid.invoice_date), '2026-03-01') + self.assertIn(paid.payment_state, ('paid', 'in_payment')) + for skipped in ('inv-void', 'inv-draft', 'inv-unver'): + self.assertFalse(self.Move.search([('x_fc_nexacloud_invoice_id', '=', skipped)])) + + def test_cron_leaves_already_posted_untouched(self): + # first run posts inv-paid; second run must not re-touch it (idempotent) + base = _inv_fixture()[0] + fixtures = [dict(base, id='inv-x', invoice_number='NEX-X', stripe_invoice_id='in_x')] + verdict = {'invoice_date': '2026-03-01', 'void': False, 'paid': True, + 'paid_at': '2026-03-02', 'amount_paid': 113.0} + cls = type(self.W) + with patch.object(cls, '_read_nexacloud_invoices', return_value=fixtures), \ + patch.object(cls, '_fc_verify', side_effect=lambda inv: verdict): + self.W._cron_sync_verified() + summary2 = self.W._cron_sync_verified() + self.assertEqual(summary2['already_posted'], 1) + self.assertEqual(summary2['posted'], 0) + self.assertEqual(self.Move.search_count( + [('x_fc_nexacloud_invoice_id', '=', 'inv-x')]), 1) diff --git a/fusion_centralize_billing/wizards/invoice_ledger.py b/fusion_centralize_billing/wizards/invoice_ledger.py index 6faf4182..c85b0e4a 100644 --- a/fusion_centralize_billing/wizards/invoice_ledger.py +++ b/fusion_centralize_billing/wizards/invoice_ledger.py @@ -12,7 +12,7 @@ ingests its output. See docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledg import json import logging import re -from datetime import timedelta +from datetime import datetime, timezone from odoo import api, fields, models from odoo.exceptions import UserError @@ -112,13 +112,23 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel): # ----- ingest side (pure Odoo; unit-tested) ------------------------------ @api.model - def _ingest_invoices(self, data, post=False): + def _ingest_invoices(self, data, post=False, verified=None): + """Upsert one account.move per NexaCloud invoice. + + ``verified`` (optional) maps nc_id -> the dict returned by ``_fc_verify`` + (date + paid status taken from the SOURCE billing system). When present for + an invoice, the source invoice_date and paid status win over NexaCloud's own + (unreliable) fields. Without it, the raw NexaCloud fields are used (manual + backfill / dry-run path).""" + verified = verified or {} 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, + summary = {"created": 0, "updated": 0, "posted": 0, "reconciled": 0, "skipped": [], "failed": [], "by_family": {}} for inv in data: nc_id = str(inv.get("id") or "") + v = verified.get(nc_id) + inv_date = (v or {}).get("invoice_date") or inv.get("invoice_date") try: with self.env.cr.savepoint(): existing = Move.search( @@ -131,12 +141,14 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel): existing.invoice_line_ids.unlink() # draft: replace lines if existing.partner_id != partner: existing.partner_id = partner.id + if inv_date and str(existing.invoice_date) != str(inv_date): + existing.invoice_date = inv_date move = existing else: move = Move.create({ "move_type": "out_invoice", "partner_id": partner.id, - "invoice_date": inv.get("invoice_date"), + "invoice_date": inv_date, "ref": inv.get("invoice_number"), "currency_id": cad.id, "x_fc_nexacloud_invoice_id": nc_id, @@ -180,9 +192,13 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel): move.write({"invoice_line_ids": line_vals}) summary["updated" if existing else "created"] += 1 if post: + if v and inv_date: + # accounting date = source invoice date (else Odoo stamps today) + move.write({"date": inv_date}) move.action_post() summary["posted"] += 1 - self._fc_reconcile_payment(move, inv) + if self._fc_reconcile_payment(move, inv, verified=v): + summary["reconciled"] += 1 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)}) @@ -238,9 +254,128 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel): summary["failed"].append({"id": nc_id, "error": "%s: %s" % (type(e).__name__, e)}) return summary - 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) + def _cron_sync_verified(self): + """Daily go-forward sync (the only safe automatic path). + + Reads NexaCloud invoices, then for each one not already in the ledger verifies + it against its SOURCE billing system (Stripe / Lago) and ingests + posts only + verified data: the real invoice date, and a reconciled payment ONLY when the + source confirms it is paid. Voids are skipped; anything that cannot be verified + is logged and left for the next run (never posted on NexaCloud's own unreliable + created_at / status / paid_at). Idempotent — already-posted invoices are left + untouched.""" + Move = self.env["account.move"] + data = self._read_nexacloud_invoices() + to_ingest, verified = [], {} + summary = {"verified": 0, "skipped_void": 0, "skipped_draft": 0, + "already_posted": 0, "unverified": []} + for inv in data: + nc_id = str(inv.get("id") or "") + existing = Move.search( + [("x_fc_nexacloud_invoice_id", "=", nc_id), + ("move_type", "=", "out_invoice")], limit=1) + if existing and existing.state == "posted": + summary["already_posted"] += 1 + continue + v = self._fc_verify(inv) + if v is None: + summary["unverified"].append(nc_id) + continue + if v.get("void"): + summary["skipped_void"] += 1 + continue + if v.get("draft"): + # not finalized at the source yet — will be picked up once it finalizes + summary["skipped_draft"] += 1 + continue + verified[nc_id] = v + to_ingest.append(inv) + summary["verified"] += 1 + res = self._ingest_invoices(to_ingest, post=True, verified=verified) + for k in ("created", "updated", "posted", "reconciled", "failed"): + summary[k] = res.get(k) + if summary["unverified"]: + _logger.warning("Ledger sync: %s invoice(s) unverified, will retry next run: %s", + len(summary["unverified"]), summary["unverified"]) + _logger.info("Ledger sync summary: %s", summary) + return summary + + # ----- source-of-truth verification (Stripe / Lago) ---------------------- + @api.model + def _fc_ts_to_date(self, ts): + """Unix timestamp (Stripe) -> 'YYYY-MM-DD' (UTC). None/blank-safe (0 = epoch).""" + if ts is None or ts == "": + return None + return datetime.fromtimestamp(int(ts), tz=timezone.utc).date().isoformat() + + @api.model + def _fc_verify(self, inv): + """Route an invoice to its source billing system for verification. + Returns a dict {invoice_date, void, paid, paid_at, amount_paid} or None if the + source can't be determined / reached (caller then leaves it for the next run).""" + sid = (inv.get("stripe_invoice_id") or "").strip() + if sid.startswith("in_"): + return self._fc_verify_stripe(sid) + if sid.startswith("lago:"): + return self._fc_verify_lago(sid[len("lago:"):]) + return None + + @api.model + def _fc_verify_stripe(self, stripe_invoice_id): + key = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.stripe_api_key") + if not key: + return None + import requests + try: + resp = requests.get( + "https://api.stripe.com/v1/invoices/%s" % stripe_invoice_id, + auth=(key, ""), timeout=20) + except Exception: # noqa: BLE001 - network failure: treat as unverifiable + _logger.exception("Stripe verify failed for %s", stripe_invoice_id) + return None + if resp.status_code != 200: + _logger.warning("Stripe verify %s -> HTTP %s", stripe_invoice_id, resp.status_code) + return None + d = resp.json() + status = d.get("status") + paid_ts = (d.get("status_transitions") or {}).get("paid_at") + return { + "invoice_date": self._fc_ts_to_date(d.get("created")), + "void": status == "void", + "draft": status == "draft", # not finalized in Stripe -> not a real invoice yet + "paid": status == "paid" or float(d.get("amount_paid") or 0) > 0, + "paid_at": self._fc_ts_to_date(paid_ts), + "amount_paid": float(d.get("amount_paid") or 0) / 100.0, + } + + @api.model + def _fc_verify_lago(self, lago_invoice_id): + cp = self.env["ir.config_parameter"].sudo() + url = cp.get_param("fusion_billing.lago_api_url") + key = cp.get_param("fusion_billing.lago_api_key") + if not url or not key: + return None + import requests + try: + resp = requests.get( + "%s/v1/invoices/%s" % (url.rstrip("/"), lago_invoice_id), + headers={"Authorization": "Bearer %s" % key}, timeout=20) + except Exception: # noqa: BLE001 - network failure: treat as unverifiable + _logger.exception("Lago verify failed for %s", lago_invoice_id) + return None + if resp.status_code != 200: + _logger.warning("Lago verify %s -> HTTP %s", lago_invoice_id, resp.status_code) + return None + d = (resp.json() or {}).get("invoice") or {} + issuing = d.get("issuing_date") # already 'YYYY-MM-DD' + return { + "invoice_date": issuing, + "void": d.get("status") == "voided", + "draft": d.get("status") == "draft", # not finalized in Lago yet + "paid": d.get("payment_status") == "succeeded", + "paid_at": issuing, # Lago exposes no clean paid-at; issuing date is the proxy + "amount_paid": float(d.get("total_paid_amount_cents") or 0) / 100.0, + } # ----- helpers ------------------------------------------------------------ @api.model @@ -311,15 +446,30 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel): 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": + def _fc_reconcile_payment(self, move, inv, verified=None): + """Register + reconcile a Stripe payment against a posted invoice. + + When ``verified`` is given, paid status / amount / date come from the SOURCE + system (Stripe/Lago); a payment is created ONLY if the source confirms paid. + Without it, NexaCloud's own (unreliable) fields are used (manual/backfill path).""" + if move.state != "posted": return False + if verified is not None: + if not verified.get("paid"): + return False + amount = verified.get("amount_paid") or move.amount_total + payment_date = verified.get("paid_at") or move.invoice_date or fields.Date.today() + else: + paid = float(inv.get("amount_paid") or 0.0) + if inv.get("status") != "paid" and paid <= 0: + return False + amount = paid or move.amount_total + payment_date = inv.get("paid_at") or move.invoice_date or fields.Date.today() 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, + "payment_date": payment_date, + "amount": amount, }) reg._create_payments() return True