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