diff --git a/fusion_centralize_billing/tests/test_invoice_ledger.py b/fusion_centralize_billing/tests/test_invoice_ledger.py index ec769e8f..c12fbfbd 100644 --- a/fusion_centralize_billing/tests/test_invoice_ledger.py +++ b/fusion_centralize_billing/tests/test_invoice_ledger.py @@ -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 diff --git a/fusion_centralize_billing/wizards/invoice_ledger.py b/fusion_centralize_billing/wizards/invoice_ledger.py index 922fefab..260a09d7 100644 --- a/fusion_centralize_billing/wizards/invoice_ledger.py +++ b/fusion_centralize_billing/wizards/invoice_ledger.py @@ -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)