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:
@@ -134,8 +134,25 @@ Income-account codes come from the COA (`nexa_coa_setup`); confirm/create at imp
|
||||
`payment_status=succeeded` via the Lago API (`billing.nexasystems.ca/api/api/v1`, key in
|
||||
Fusion-Chat; Lago host 192.168.1.117, double-hop ssh via supabase-prod).
|
||||
Partner names came from the NexaCloud `company` field (not the user's full_name).
|
||||
- **GO-FORWARD CAVEAT — do NOT enable the daily cron yet.** `_ingest_invoices` /
|
||||
`_post_and_reconcile_paid` still date + gauge payment from NexaCloud's own fields, which
|
||||
are unreliable for new invoices too. Before automating, the ledger must verify each new
|
||||
invoice's date + paid status against Stripe (and Lago, until decommissioned) — i.e. fold
|
||||
the verification used in this backfill into the ingest path.
|
||||
- **GO-FORWARD: verified sync is LIVE (2026-05-27).** The verification used in the backfill
|
||||
is now folded into the ingest path, and the daily cron is enabled:
|
||||
- `_fc_verify(inv)` routes each invoice to its source by `stripe_invoice_id` prefix
|
||||
(`in_` → Stripe REST `GET /v1/invoices/{id}`; `lago:` → Lago REST) and returns
|
||||
`{invoice_date, void, draft, paid, paid_at, amount_paid}` taken from the SOURCE — or
|
||||
`None` if it can't be determined/reached. Credentials live in `ir.config_parameter`:
|
||||
`fusion_billing.stripe_api_key` (set, live), `fusion_billing.lago_api_url` /
|
||||
`fusion_billing.lago_api_key` (optional; unset — no new Lago invoices expected).
|
||||
- `_cron_sync_verified()` reads all NexaCloud invoices, skips ones already posted, then
|
||||
for the rest: skips **void** and **draft** (not finalized at source), logs **unverified**
|
||||
for retry next run, and ingests the rest with `_ingest_invoices(post=True, verified=…)`
|
||||
so the move uses the source invoice_date (accounting date too) and a payment is
|
||||
reconciled ONLY when the source confirms paid. Never acts on NexaCloud's raw fields.
|
||||
- Cron `cron_fc_invoice_ledger` on nexamain: **active**, daily at 06:00 UTC. (A stale
|
||||
pre-existing copy of this record still called the removed `_cron_ingest_recent`; because
|
||||
the data file is `noupdate="1"` the upgrade didn't rewrite it, so its server-action code
|
||||
+ name were corrected once via SQL. Fresh installs get the right definition from the XML.)
|
||||
- First live run (2026-05-27): 23 already-posted, 1 void + 2 Stripe drafts + 5 genuine
|
||||
$0 invoices all correctly skipped, **0 new posted**, ledger intact at $3,403.46.
|
||||
- Verification helpers are unit-tested without network (routing short-circuits when no
|
||||
credentials are set; the cron is exercised with `_read_nexacloud_invoices` / `_fc_verify`
|
||||
patched). Full suite green on odoo-trial (`FCB_EXIT=0`).
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
"name": "Fusion Centralized Billing",
|
||||
"version": "19.0.1.0.0",
|
||||
"version": "19.0.1.1.0",
|
||||
"category": "Accounting/Subscriptions",
|
||||
"summary": "Centralized billing engine for all NexaSystems services — metered usage, "
|
||||
"per-app billing API, and outbound webhooks on top of Odoo Enterprise subscriptions.",
|
||||
|
||||
@@ -19,4 +19,17 @@
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Go-forward NexaCloud ledger sync. Ships INACTIVE: only enable once the Stripe
|
||||
(and Lago) API credentials are set on the instance and a manual run is verified,
|
||||
because the sync verifies each invoice against those sources before posting. -->
|
||||
<record id="cron_fc_invoice_ledger" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Sync NexaCloud invoices (Stripe/Lago verified)</field>
|
||||
<field name="model_id" ref="model_fusion_billing_invoice_ledger_wizard"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_sync_verified()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">False</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,7 +12,7 @@ ingests its output. See docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledg
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
@@ -112,13 +112,23 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
||||
|
||||
# ----- ingest side (pure Odoo; unit-tested) ------------------------------
|
||||
@api.model
|
||||
def _ingest_invoices(self, data, post=False):
|
||||
def _ingest_invoices(self, data, post=False, verified=None):
|
||||
"""Upsert one account.move per NexaCloud invoice.
|
||||
|
||||
``verified`` (optional) maps nc_id -> the dict returned by ``_fc_verify``
|
||||
(date + paid status taken from the SOURCE billing system). When present for
|
||||
an invoice, the source invoice_date and paid status win over NexaCloud's own
|
||||
(unreliable) fields. Without it, the raw NexaCloud fields are used (manual
|
||||
backfill / dry-run path)."""
|
||||
verified = verified or {}
|
||||
Move = self.env["account.move"]
|
||||
cad = self.env.ref("base.CAD", raise_if_not_found=False) or self.env.company.currency_id
|
||||
summary = {"created": 0, "updated": 0, "posted": 0,
|
||||
summary = {"created": 0, "updated": 0, "posted": 0, "reconciled": 0,
|
||||
"skipped": [], "failed": [], "by_family": {}}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get("id") or "")
|
||||
v = verified.get(nc_id)
|
||||
inv_date = (v or {}).get("invoice_date") or inv.get("invoice_date")
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
existing = Move.search(
|
||||
@@ -131,12 +141,14 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
||||
existing.invoice_line_ids.unlink() # draft: replace lines
|
||||
if existing.partner_id != partner:
|
||||
existing.partner_id = partner.id
|
||||
if inv_date and str(existing.invoice_date) != str(inv_date):
|
||||
existing.invoice_date = inv_date
|
||||
move = existing
|
||||
else:
|
||||
move = Move.create({
|
||||
"move_type": "out_invoice",
|
||||
"partner_id": partner.id,
|
||||
"invoice_date": inv.get("invoice_date"),
|
||||
"invoice_date": inv_date,
|
||||
"ref": inv.get("invoice_number"),
|
||||
"currency_id": cad.id,
|
||||
"x_fc_nexacloud_invoice_id": nc_id,
|
||||
@@ -180,9 +192,13 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
||||
move.write({"invoice_line_ids": line_vals})
|
||||
summary["updated" if existing else "created"] += 1
|
||||
if post:
|
||||
if v and inv_date:
|
||||
# accounting date = source invoice date (else Odoo stamps today)
|
||||
move.write({"date": inv_date})
|
||||
move.action_post()
|
||||
summary["posted"] += 1
|
||||
self._fc_reconcile_payment(move, inv)
|
||||
if self._fc_reconcile_payment(move, inv, verified=v):
|
||||
summary["reconciled"] += 1
|
||||
except Exception as e: # noqa: BLE001 - per-invoice isolation
|
||||
_logger.exception("Ledger ingest: invoice %s failed", nc_id)
|
||||
summary["failed"].append({"id": nc_id, "error": "%s: %s" % (type(e).__name__, e)})
|
||||
@@ -238,9 +254,128 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
||||
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)
|
||||
def _cron_sync_verified(self):
|
||||
"""Daily go-forward sync (the only safe automatic path).
|
||||
|
||||
Reads NexaCloud invoices, then for each one not already in the ledger verifies
|
||||
it against its SOURCE billing system (Stripe / Lago) and ingests + posts only
|
||||
verified data: the real invoice date, and a reconciled payment ONLY when the
|
||||
source confirms it is paid. Voids are skipped; anything that cannot be verified
|
||||
is logged and left for the next run (never posted on NexaCloud's own unreliable
|
||||
created_at / status / paid_at). Idempotent — already-posted invoices are left
|
||||
untouched."""
|
||||
Move = self.env["account.move"]
|
||||
data = self._read_nexacloud_invoices()
|
||||
to_ingest, verified = [], {}
|
||||
summary = {"verified": 0, "skipped_void": 0, "skipped_draft": 0,
|
||||
"already_posted": 0, "unverified": []}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get("id") or "")
|
||||
existing = Move.search(
|
||||
[("x_fc_nexacloud_invoice_id", "=", nc_id),
|
||||
("move_type", "=", "out_invoice")], limit=1)
|
||||
if existing and existing.state == "posted":
|
||||
summary["already_posted"] += 1
|
||||
continue
|
||||
v = self._fc_verify(inv)
|
||||
if v is None:
|
||||
summary["unverified"].append(nc_id)
|
||||
continue
|
||||
if v.get("void"):
|
||||
summary["skipped_void"] += 1
|
||||
continue
|
||||
if v.get("draft"):
|
||||
# not finalized at the source yet — will be picked up once it finalizes
|
||||
summary["skipped_draft"] += 1
|
||||
continue
|
||||
verified[nc_id] = v
|
||||
to_ingest.append(inv)
|
||||
summary["verified"] += 1
|
||||
res = self._ingest_invoices(to_ingest, post=True, verified=verified)
|
||||
for k in ("created", "updated", "posted", "reconciled", "failed"):
|
||||
summary[k] = res.get(k)
|
||||
if summary["unverified"]:
|
||||
_logger.warning("Ledger sync: %s invoice(s) unverified, will retry next run: %s",
|
||||
len(summary["unverified"]), summary["unverified"])
|
||||
_logger.info("Ledger sync summary: %s", summary)
|
||||
return summary
|
||||
|
||||
# ----- source-of-truth verification (Stripe / Lago) ----------------------
|
||||
@api.model
|
||||
def _fc_ts_to_date(self, ts):
|
||||
"""Unix timestamp (Stripe) -> 'YYYY-MM-DD' (UTC). None/blank-safe (0 = epoch)."""
|
||||
if ts is None or ts == "":
|
||||
return None
|
||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc).date().isoformat()
|
||||
|
||||
@api.model
|
||||
def _fc_verify(self, inv):
|
||||
"""Route an invoice to its source billing system for verification.
|
||||
Returns a dict {invoice_date, void, paid, paid_at, amount_paid} or None if the
|
||||
source can't be determined / reached (caller then leaves it for the next run)."""
|
||||
sid = (inv.get("stripe_invoice_id") or "").strip()
|
||||
if sid.startswith("in_"):
|
||||
return self._fc_verify_stripe(sid)
|
||||
if sid.startswith("lago:"):
|
||||
return self._fc_verify_lago(sid[len("lago:"):])
|
||||
return None
|
||||
|
||||
@api.model
|
||||
def _fc_verify_stripe(self, stripe_invoice_id):
|
||||
key = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.stripe_api_key")
|
||||
if not key:
|
||||
return None
|
||||
import requests
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://api.stripe.com/v1/invoices/%s" % stripe_invoice_id,
|
||||
auth=(key, ""), timeout=20)
|
||||
except Exception: # noqa: BLE001 - network failure: treat as unverifiable
|
||||
_logger.exception("Stripe verify failed for %s", stripe_invoice_id)
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
_logger.warning("Stripe verify %s -> HTTP %s", stripe_invoice_id, resp.status_code)
|
||||
return None
|
||||
d = resp.json()
|
||||
status = d.get("status")
|
||||
paid_ts = (d.get("status_transitions") or {}).get("paid_at")
|
||||
return {
|
||||
"invoice_date": self._fc_ts_to_date(d.get("created")),
|
||||
"void": status == "void",
|
||||
"draft": status == "draft", # not finalized in Stripe -> not a real invoice yet
|
||||
"paid": status == "paid" or float(d.get("amount_paid") or 0) > 0,
|
||||
"paid_at": self._fc_ts_to_date(paid_ts),
|
||||
"amount_paid": float(d.get("amount_paid") or 0) / 100.0,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _fc_verify_lago(self, lago_invoice_id):
|
||||
cp = self.env["ir.config_parameter"].sudo()
|
||||
url = cp.get_param("fusion_billing.lago_api_url")
|
||||
key = cp.get_param("fusion_billing.lago_api_key")
|
||||
if not url or not key:
|
||||
return None
|
||||
import requests
|
||||
try:
|
||||
resp = requests.get(
|
||||
"%s/v1/invoices/%s" % (url.rstrip("/"), lago_invoice_id),
|
||||
headers={"Authorization": "Bearer %s" % key}, timeout=20)
|
||||
except Exception: # noqa: BLE001 - network failure: treat as unverifiable
|
||||
_logger.exception("Lago verify failed for %s", lago_invoice_id)
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
_logger.warning("Lago verify %s -> HTTP %s", lago_invoice_id, resp.status_code)
|
||||
return None
|
||||
d = (resp.json() or {}).get("invoice") or {}
|
||||
issuing = d.get("issuing_date") # already 'YYYY-MM-DD'
|
||||
return {
|
||||
"invoice_date": issuing,
|
||||
"void": d.get("status") == "voided",
|
||||
"draft": d.get("status") == "draft", # not finalized in Lago yet
|
||||
"paid": d.get("payment_status") == "succeeded",
|
||||
"paid_at": issuing, # Lago exposes no clean paid-at; issuing date is the proxy
|
||||
"amount_paid": float(d.get("total_paid_amount_cents") or 0) / 100.0,
|
||||
}
|
||||
|
||||
# ----- helpers ------------------------------------------------------------
|
||||
@api.model
|
||||
@@ -311,15 +446,30 @@ class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
||||
return j
|
||||
|
||||
@api.model
|
||||
def _fc_reconcile_payment(self, move, inv):
|
||||
paid = float(inv.get("amount_paid") or 0.0)
|
||||
if (inv.get("status") != "paid" and paid <= 0) or move.state != "posted":
|
||||
def _fc_reconcile_payment(self, move, inv, verified=None):
|
||||
"""Register + reconcile a Stripe payment against a posted invoice.
|
||||
|
||||
When ``verified`` is given, paid status / amount / date come from the SOURCE
|
||||
system (Stripe/Lago); a payment is created ONLY if the source confirms paid.
|
||||
Without it, NexaCloud's own (unreliable) fields are used (manual/backfill path)."""
|
||||
if move.state != "posted":
|
||||
return False
|
||||
if verified is not None:
|
||||
if not verified.get("paid"):
|
||||
return False
|
||||
amount = verified.get("amount_paid") or move.amount_total
|
||||
payment_date = verified.get("paid_at") or move.invoice_date or fields.Date.today()
|
||||
else:
|
||||
paid = float(inv.get("amount_paid") or 0.0)
|
||||
if inv.get("status") != "paid" and paid <= 0:
|
||||
return False
|
||||
amount = paid or move.amount_total
|
||||
payment_date = inv.get("paid_at") or move.invoice_date or fields.Date.today()
|
||||
reg = self.env["account.payment.register"].with_context(
|
||||
active_model="account.move", active_ids=move.ids).create({
|
||||
"journal_id": self._fc_stripe_journal().id,
|
||||
"payment_date": inv.get("paid_at") or move.invoice_date or fields.Date.today(),
|
||||
"amount": paid or move.amount_total,
|
||||
"payment_date": payment_date,
|
||||
"amount": amount,
|
||||
})
|
||||
reg._create_payments()
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user