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

@@ -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)