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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user