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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-27 18:37:36 -04:00
parent feddca19d6
commit e36318f7a5
5 changed files with 299 additions and 19 deletions

View File

@@ -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 `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). 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). 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` / - **GO-FORWARD: verified sync is LIVE (2026-05-27).** The verification used in the backfill
`_post_and_reconcile_paid` still date + gauge payment from NexaCloud's own fields, which is now folded into the ingest path, and the daily cron is enabled:
are unreliable for new invoices too. Before automating, the ledger must verify each new - `_fc_verify(inv)` routes each invoice to its source by `stripe_invoice_id` prefix
invoice's date + paid status against Stripe (and Lago, until decommissioned) — i.e. fold (`in_` → Stripe REST `GET /v1/invoices/{id}`; `lago:` → Lago REST) and returns
the verification used in this backfill into the ingest path. `{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`).

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
"name": "Fusion Centralized Billing", "name": "Fusion Centralized Billing",
"version": "19.0.1.0.0", "version": "19.0.1.1.0",
"category": "Accounting/Subscriptions", "category": "Accounting/Subscriptions",
"summary": "Centralized billing engine for all NexaSystems services — metered usage, " "summary": "Centralized billing engine for all NexaSystems services — metered usage, "
"per-app billing API, and outbound webhooks on top of Odoo Enterprise subscriptions.", "per-app billing API, and outbound webhooks on top of Odoo Enterprise subscriptions.",

View File

@@ -19,4 +19,17 @@
<field name="interval_type">minutes</field> <field name="interval_type">minutes</field>
<field name="active">True</field> <field name="active">True</field>
</record> </record>
<!-- Go-forward NexaCloud ledger sync. Ships INACTIVE: only enable once the Stripe
(and Lago) API credentials are set on the instance and a manual run is verified,
because the sync verifies each invoice against those sources before posting. -->
<record id="cron_fc_invoice_ledger" model="ir.cron">
<field name="name">Fusion Billing: Sync NexaCloud invoices (Stripe/Lago verified)</field>
<field name="model_id" ref="model_fusion_billing_invoice_ledger_wizard"/>
<field name="state">code</field>
<field name="code">model._cron_sync_verified()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">False</field>
</record>
</odoo> </odoo>

View File

@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 # License OPL-1
from unittest.mock import patch
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase, tagged from odoo.tests.common import TransactionCase, tagged
@@ -161,3 +163,101 @@ class TestLedgerIngest(TransactionCase):
counts = self.W._fc_prune_metered_shadow() counts = self.W._fc_prune_metered_shadow()
self.assertFalse(shadow.exists()) self.assertFalse(shadow.exists())
self.assertGreaterEqual(counts.get('subscriptions', 0), 1) 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)

View File

@@ -12,7 +12,7 @@ ingests its output. See docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledg
import json import json
import logging import logging
import re import re
from datetime import timedelta from datetime import datetime, timezone
from odoo import api, fields, models from odoo import api, fields, models
from odoo.exceptions import UserError from odoo.exceptions import UserError
@@ -112,13 +112,23 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel):
# ----- ingest side (pure Odoo; unit-tested) ------------------------------ # ----- ingest side (pure Odoo; unit-tested) ------------------------------
@api.model @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"] Move = self.env["account.move"]
cad = self.env.ref("base.CAD", raise_if_not_found=False) or self.env.company.currency_id 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": {}} "skipped": [], "failed": [], "by_family": {}}
for inv in data: for inv in data:
nc_id = str(inv.get("id") or "") 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: try:
with self.env.cr.savepoint(): with self.env.cr.savepoint():
existing = Move.search( existing = Move.search(
@@ -131,12 +141,14 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel):
existing.invoice_line_ids.unlink() # draft: replace lines existing.invoice_line_ids.unlink() # draft: replace lines
if existing.partner_id != partner: if existing.partner_id != partner:
existing.partner_id = partner.id existing.partner_id = partner.id
if inv_date and str(existing.invoice_date) != str(inv_date):
existing.invoice_date = inv_date
move = existing move = existing
else: else:
move = Move.create({ move = Move.create({
"move_type": "out_invoice", "move_type": "out_invoice",
"partner_id": partner.id, "partner_id": partner.id,
"invoice_date": inv.get("invoice_date"), "invoice_date": inv_date,
"ref": inv.get("invoice_number"), "ref": inv.get("invoice_number"),
"currency_id": cad.id, "currency_id": cad.id,
"x_fc_nexacloud_invoice_id": nc_id, "x_fc_nexacloud_invoice_id": nc_id,
@@ -180,9 +192,13 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel):
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:
if v and inv_date:
# accounting date = source invoice date (else Odoo stamps today)
move.write({"date": inv_date})
move.action_post() move.action_post()
summary["posted"] += 1 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 except Exception as e: # noqa: BLE001 - per-invoice isolation
_logger.exception("Ledger ingest: invoice %s failed", nc_id) _logger.exception("Ledger ingest: invoice %s failed", nc_id)
summary["failed"].append({"id": nc_id, "error": "%s: %s" % (type(e).__name__, e)}) 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)}) summary["failed"].append({"id": nc_id, "error": "%s: %s" % (type(e).__name__, e)})
return summary return summary
def _cron_ingest_recent(self): def _cron_sync_verified(self):
since = fields.Datetime.to_string(fields.Datetime.now() - timedelta(days=2)) """Daily go-forward sync (the only safe automatic path).
return self._ingest_invoices(self._read_nexacloud_invoices(since=since), post=True)
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 ------------------------------------------------------------ # ----- helpers ------------------------------------------------------------
@api.model @api.model
@@ -311,15 +446,30 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel):
return j return j
@api.model @api.model
def _fc_reconcile_payment(self, move, inv): def _fc_reconcile_payment(self, move, inv, verified=None):
paid = float(inv.get("amount_paid") or 0.0) """Register + reconcile a Stripe payment against a posted invoice.
if (inv.get("status") != "paid" and paid <= 0) or move.state != "posted":
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 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( reg = self.env["account.payment.register"].with_context(
active_model="account.move", active_ids=move.ids).create({ active_model="account.move", active_ids=move.ids).create({
"journal_id": self._fc_stripe_journal().id, "journal_id": self._fc_stripe_journal().id,
"payment_date": inv.get("paid_at") or move.invoice_date or fields.Date.today(), "payment_date": payment_date,
"amount": paid or move.amount_total, "amount": amount,
}) })
reg._create_payments() reg._create_payments()
return True return True