_post_and_reconcile_paid: for invoices NexaCloud marks paid, set the ledger entry's invoice_date AND accounting date to the original NexaCloud date, post, then reconcile the Stripe payment dated to the actual paid_at. Unpaid invoices stay draft. Per-invoice isolated. 76 tests green on odoo-trial.
155 lines
7.4 KiB
Python
155 lines
7.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1
|
|
from odoo.exceptions import UserError
|
|
from odoo.tests.common import TransactionCase, tagged
|
|
|
|
|
|
def _inv_fixture():
|
|
return [{
|
|
'id': 'inv-1', 'stripe_invoice_id': 'in_test1', 'invoice_number': 'NEX-0001',
|
|
'user_external_id': 'u-1', 'partner_name': 'Acme', 'partner_email': 'ar@acme.test',
|
|
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'open',
|
|
'subtotal': 100.0, 'tax': 13.0, 'amount_paid': 0.0, 'paid_at': None,
|
|
'items': [{'description': 'Odoo ERP Hosting (2026-05-01 to 2026-06-01)',
|
|
'quantity': 1.0, 'unit_price': 100.0, 'amount': 100.0}],
|
|
}]
|
|
|
|
|
|
@tagged('post_install', '-at_install')
|
|
class TestLedgerFamily(TransactionCase):
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
|
|
|
def test_family_classification(self):
|
|
f = self.W._fc_family_for
|
|
self.assertEqual(f('Odoo ERP Hosting (2026-05-01 to 2026-06-01)'), 'hosting')
|
|
self.assertEqual(f('WordPress Website Hosting - Managed (at $50.00 / month)'), 'hosting')
|
|
self.assertEqual(f('Managed Odoo - Standard (at $49.99 / month)'), 'managed')
|
|
self.assertEqual(f('Daily Backup Protection'), 'addons')
|
|
self.assertEqual(f('Remaining time on Daily Backup Protection after 27 May 2026'), 'addons')
|
|
self.assertEqual(f('Something Unmapped'), 'other')
|
|
|
|
def test_income_account_per_family_distinct(self):
|
|
a_host = self.W._fc_income_account('hosting')
|
|
a_add = self.W._fc_income_account('addons')
|
|
self.assertEqual(a_host.account_type, 'income')
|
|
self.assertNotEqual(a_host, a_add)
|
|
self.assertEqual(self.W._fc_income_account('hosting'), a_host) # idempotent
|
|
|
|
|
|
@tagged('post_install', '-at_install')
|
|
class TestLedgerTax(TransactionCase):
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
|
|
|
def test_tax_for_13pct_is_a_13_percent_sale_tax(self):
|
|
tax = self.W._fc_tax_for(100.0, 13.0)
|
|
self.assertTrue(tax, "expected an HST/13% sale tax on the Canadian COA")
|
|
self.assertEqual(tax.type_tax_use, 'sale')
|
|
res = tax.compute_all(100.0)
|
|
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 13.0, places=2)
|
|
|
|
def test_tax_for_zero_is_zero_or_empty(self):
|
|
tax = self.W._fc_tax_for(100.0, 0.0)
|
|
if tax:
|
|
res = tax.compute_all(100.0)
|
|
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 0.0, places=2)
|
|
|
|
|
|
@tagged('post_install', '-at_install')
|
|
class TestLedgerIngest(TransactionCase):
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
|
self.Move = self.env['account.move']
|
|
|
|
def test_ingest_creates_draft_invoice_with_right_totals(self):
|
|
self.W._ingest_invoices(_inv_fixture(), post=False)
|
|
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
|
self.assertEqual(len(mv), 1)
|
|
self.assertEqual(mv.move_type, 'out_invoice')
|
|
self.assertEqual(mv.state, 'draft')
|
|
self.assertAlmostEqual(mv.amount_untaxed, 100.0, places=2)
|
|
self.assertAlmostEqual(mv.amount_tax, 13.0, places=2) # equals source tax
|
|
self.assertAlmostEqual(mv.amount_total, 113.0, places=2)
|
|
self.assertEqual(mv.partner_id.email, 'ar@acme.test')
|
|
self.assertEqual(mv.invoice_line_ids.account_id, self.W._fc_income_account('hosting'))
|
|
|
|
def test_ingest_is_idempotent(self):
|
|
self.W._ingest_invoices(_inv_fixture(), post=False)
|
|
self.W._ingest_invoices(_inv_fixture(), post=False)
|
|
self.assertEqual(self.Move.search_count(
|
|
[('x_fc_nexacloud_invoice_id', '=', 'inv-1')]), 1)
|
|
|
|
def test_paid_invoice_is_reconciled_and_shows_paid(self):
|
|
data = _inv_fixture()
|
|
data[0].update({'status': 'paid', 'amount_paid': 113.0, 'paid_at': '2026-05-02'})
|
|
self.W._ingest_invoices(data, post=True)
|
|
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
|
self.assertEqual(mv.state, 'posted')
|
|
self.assertIn(mv.payment_state, ('paid', 'in_payment'))
|
|
|
|
def test_post_ingested_posts_drafts(self):
|
|
self.W._ingest_invoices(_inv_fixture(), post=False)
|
|
n = self.W._post_ingested()
|
|
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
|
self.assertEqual(mv.state, 'posted')
|
|
self.assertGreaterEqual(n, 1)
|
|
|
|
def test_read_invoices_guards_missing_dsn(self):
|
|
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
|
with self.assertRaises(UserError):
|
|
self.W._read_nexacloud_invoices()
|
|
|
|
def test_unitemized_subtotal_gets_reconciling_line(self):
|
|
data = [{
|
|
'id': 'inv-base', 'stripe_invoice_id': 'in_base', 'invoice_number': 'NEX-BASE',
|
|
'user_external_id': 'u-2', 'partner_name': 'Globex', 'partner_email': 'ops@globex.test',
|
|
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'open',
|
|
'subtotal': 200.0, 'tax': 0.0, 'amount_paid': 0.0, 'paid_at': None,
|
|
'items': [], # base plan billed via Stripe only — no line items
|
|
}]
|
|
self.W._ingest_invoices(data, post=False)
|
|
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-base')])
|
|
self.assertAlmostEqual(mv.amount_untaxed, 200.0, places=2) # captured via reconciling line
|
|
self.assertTrue(any('base/unitemized' in (l.name or '') for l in mv.invoice_line_ids))
|
|
|
|
def test_post_and_reconcile_paid_only(self):
|
|
base = _inv_fixture()[0]
|
|
paid = dict(base, id='inv-paid', invoice_number='NEX-PAID',
|
|
status='paid', amount_paid=113.0, paid_at='2026-05-02',
|
|
invoice_date='2026-05-01')
|
|
unpaid = dict(base, id='inv-unpaid', invoice_number='NEX-UNPAID',
|
|
status='open', amount_paid=0.0, invoice_date='2026-04-01')
|
|
self.W._ingest_invoices([paid, unpaid], post=False)
|
|
summary = self.W._post_and_reconcile_paid([paid, unpaid])
|
|
pm = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-paid')])
|
|
um = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-unpaid')])
|
|
self.assertEqual(pm.state, 'posted')
|
|
self.assertIn(pm.payment_state, ('paid', 'in_payment'))
|
|
self.assertEqual(str(pm.invoice_date), '2026-05-01') # original invoice date kept
|
|
self.assertEqual(um.state, 'draft') # unpaid stays draft
|
|
self.assertEqual(summary['posted'], 1)
|
|
self.assertEqual(summary['skipped_unpaid'], 1)
|
|
|
|
def test_partner_named_by_company_not_person(self):
|
|
data = _inv_fixture()
|
|
data[0]['partner_company'] = 'Acme Holdings Inc' # full_name is "Acme"; company wins
|
|
self.W._ingest_invoices(data, post=False)
|
|
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
|
self.assertEqual(mv.partner_id.name, 'Acme Holdings Inc')
|
|
self.assertTrue(mv.partner_id.is_company)
|
|
|
|
def test_prune_shadow_removes_shadow_subs_only(self):
|
|
p = self.env['res.partner'].sudo().create({'name': 'X'})
|
|
shadow = self.env['sale.order'].sudo().create({'partner_id': p.id, 'x_fc_shadow': True})
|
|
counts = self.W._fc_prune_metered_shadow()
|
|
self.assertFalse(shadow.exists())
|
|
self.assertGreaterEqual(counts.get('subscriptions', 0), 1)
|