feat(billing): post + reconcile only PAID invoices, keeping original dates
_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.
This commit is contained in:
@@ -120,6 +120,24 @@ class TestLedgerIngest(TransactionCase):
|
||||
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
|
||||
|
||||
@@ -197,6 +197,41 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
||||
_logger.exception("Ledger post: move %s failed", mv.id)
|
||||
return posted
|
||||
|
||||
@api.model
|
||||
def _post_and_reconcile_paid(self, data):
|
||||
"""Post + reconcile ONLY the invoices NexaCloud marks paid, dating the ledger entry
|
||||
to the ORIGINAL invoice date and the payment to the actual paid_at. Leaves unpaid
|
||||
invoices as draft. Per-invoice isolated."""
|
||||
Move = self.env["account.move"]
|
||||
summary = {"posted": 0, "reconciled": 0, "skipped_unpaid": 0,
|
||||
"skipped_missing": 0, "failed": []}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get("id") or "")
|
||||
paid = float(inv.get("amount_paid") or 0.0)
|
||||
if inv.get("status") != "paid" and paid <= 0:
|
||||
summary["skipped_unpaid"] += 1
|
||||
continue
|
||||
mv = Move.search([("x_fc_nexacloud_invoice_id", "=", nc_id),
|
||||
("move_type", "=", "out_invoice")], limit=1)
|
||||
if not mv:
|
||||
summary["skipped_missing"] += 1
|
||||
continue
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
if mv.state == "draft":
|
||||
inv_date = inv.get("invoice_date")
|
||||
# keep the original invoice + accounting date (not today)
|
||||
mv.write({"invoice_date": inv_date, "date": inv_date})
|
||||
mv.action_post()
|
||||
summary["posted"] += 1
|
||||
if mv.payment_state not in ("paid", "in_payment", "reversed"):
|
||||
if self._fc_reconcile_payment(mv, inv):
|
||||
summary["reconciled"] += 1
|
||||
except Exception as e: # noqa: BLE001 - per-invoice isolation
|
||||
_logger.exception("Post+pay: invoice %s failed", nc_id)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user