feat(billing): NexaCloud invoice ledger — ingest invoices to account.move
Odoo becomes the accounting SoR by ingesting NexaCloud's real Stripe invoices (read-only via the existing DSN) into native account.move customer invoices: per-service-family income accounts, tax derived to match the source invoice.tax, Stripe payments reconciled via account.payment.register (invoice shows paid), idempotent on x_fc_nexacloud_invoice_id, draft-first with bulk-post + a daily cron (inactive). Plus a prune helper for the now-obsolete metered shadow data. 73 tests green on odoo-trial. Account codes use dots (Odoo 19 rejects '-').
This commit is contained in:
@@ -49,6 +49,7 @@ reference files from the container before implementing subscription/account inte
|
||||
"security/ir.model.access.csv",
|
||||
"data/ir_cron.xml",
|
||||
"views/import_wizard_views.xml",
|
||||
"views/invoice_ledger_views.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"application": False,
|
||||
|
||||
@@ -7,3 +7,4 @@ from . import webhook
|
||||
from . import reconciliation
|
||||
from . import sale_order
|
||||
from . import res_partner
|
||||
from . import account_move
|
||||
|
||||
17
fusion_centralize_billing/models/account_move.py
Normal file
17
fusion_centralize_billing/models/account_move.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = "account.move"
|
||||
|
||||
x_fc_nexacloud_invoice_id = fields.Char(
|
||||
index=True, copy=False, help="Source NexaCloud invoice id — ledger idempotency key.")
|
||||
x_fc_stripe_invoice_id = fields.Char(index=True, copy=False)
|
||||
|
||||
_fc_nc_invoice_uniq = models.Constraint(
|
||||
"unique(x_fc_nexacloud_invoice_id)",
|
||||
"One Odoo invoice per NexaCloud invoice id.",
|
||||
)
|
||||
@@ -10,3 +10,4 @@ access_fusion_billing_metric_acct,fusion.billing.metric accountant,model_fusion_
|
||||
access_fusion_billing_charge_acct,fusion.billing.charge accountant,model_fusion_billing_charge,account.group_account_manager,1,1,1,0
|
||||
access_fusion_billing_reconciliation_acct,fusion.billing.reconciliation accountant,model_fusion_billing_reconciliation,account.group_account_manager,1,1,1,0
|
||||
access_fusion_billing_import_wizard,fusion.billing.import.wizard,model_fusion_billing_import_wizard,base.group_system,1,1,1,1
|
||||
access_fc_invoice_ledger_wizard,fusion.billing.invoice.ledger.wizard,model_fusion_billing_invoice_ledger_wizard,base.group_system,1,1,1,1
|
||||
|
||||
|
@@ -5,3 +5,4 @@ from . import test_api
|
||||
from . import test_webhook
|
||||
from . import test_importer
|
||||
from . import test_reconciliation
|
||||
from . import test_invoice_ledger
|
||||
|
||||
115
fusion_centralize_billing/tests/test_invoice_ledger.py
Normal file
115
fusion_centralize_billing/tests/test_invoice_ledger.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# -*- 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_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)
|
||||
44
fusion_centralize_billing/views/invoice_ledger_views.xml
Normal file
44
fusion_centralize_billing/views/invoice_ledger_views.xml
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fc_invoice_ledger_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.billing.invoice.ledger.wizard.form</field>
|
||||
<field name="model">fusion.billing.invoice.ledger.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Ingest NexaCloud Invoices">
|
||||
<group>
|
||||
<field name="dry_run"/>
|
||||
<field name="auto_post"/>
|
||||
</group>
|
||||
<group string="Result" invisible="not result_summary">
|
||||
<field name="result_summary" nolabel="1" widget="text"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_run" type="object" string="Run" class="btn-primary"/>
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fc_invoice_ledger_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Ingest NexaCloud Invoices</field>
|
||||
<field name="res_model">fusion.billing.invoice.ledger.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fc_invoice_ledger" name="Ingest NexaCloud Invoices"
|
||||
parent="menu_fusion_billing_root"
|
||||
action="action_fc_invoice_ledger_wizard" sequence="20"
|
||||
groups="base.group_system"/>
|
||||
|
||||
<record id="cron_fc_invoice_ledger" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Ingest NexaCloud invoices (daily)</field>
|
||||
<field name="model_id" ref="model_fusion_billing_invoice_ledger_wizard"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.create({'dry_run': False, 'auto_post': True})._cron_ingest_recent()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">False</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1 +1,2 @@
|
||||
from . import import_wizard
|
||||
from . import invoice_ledger
|
||||
|
||||
275
fusion_centralize_billing/wizards/invoice_ledger.py
Normal file
275
fusion_centralize_billing/wizards/invoice_ledger.py
Normal file
@@ -0,0 +1,275 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""NexaCloud → Odoo invoice ledger ingester.
|
||||
|
||||
Reads NexaCloud's real (Stripe-billed) invoices and creates native Odoo
|
||||
``account.move`` customer invoices — posted, with the Stripe payments reconciled and
|
||||
HST modelled — so Odoo is the accounting system of record. Revenue is split by service
|
||||
family into distinct income accounts. NexaCloud/Stripe keep doing the billing; Odoo
|
||||
ingests its output. See docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
||||
_name = "fusion.billing.invoice.ledger.wizard"
|
||||
_description = "Fusion Billing — NexaCloud Invoice Ledger Ingester"
|
||||
|
||||
dry_run = fields.Boolean(default=True)
|
||||
auto_post = fields.Boolean(
|
||||
default=False, help="Post invoices immediately (else leave draft for review).")
|
||||
result_summary = fields.Text(readonly=True)
|
||||
|
||||
# description keyword -> service family (checked in order; hosting before managed)
|
||||
_FAMILY_KEYWORDS = [
|
||||
("hosting", ["odoo erp hosting", "wordpress website hosting"]),
|
||||
("managed", ["managed"]),
|
||||
("addons", ["daily backup", "whatsapp", "forms builder", "white label"]),
|
||||
]
|
||||
|
||||
def action_run(self):
|
||||
self.ensure_one()
|
||||
data = self._read_nexacloud_invoices()
|
||||
if self.dry_run:
|
||||
class _Rollback(Exception):
|
||||
pass
|
||||
|
||||
res = {}
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
res.update(self._ingest_invoices(data, post=False))
|
||||
raise _Rollback()
|
||||
except _Rollback:
|
||||
pass
|
||||
res["dry_run"] = True
|
||||
else:
|
||||
res = self._ingest_invoices(data, post=self.auto_post)
|
||||
self.result_summary = json.dumps(res, indent=2, default=str)
|
||||
if res.get("failed"):
|
||||
_logger.error("Ledger ingest: %s failed: %s", len(res["failed"]), res["failed"])
|
||||
return {"type": "ir.actions.act_window", "res_model": self._name,
|
||||
"res_id": self.id, "view_mode": "form", "target": "new"}
|
||||
|
||||
# ----- read side (the ONLY code that touches NexaCloud) ------------------
|
||||
def _read_nexacloud_invoices(self, since=None):
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
dsn = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.nexacloud_dsn")
|
||||
if not dsn:
|
||||
raise UserError("NexaCloud DSN not configured (fusion_billing.nexacloud_dsn).")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
where = "WHERE i.created_at >= %(since)s" if since else ""
|
||||
cur.execute(
|
||||
"SELECT i.id, i.stripe_invoice_id, i.invoice_number, "
|
||||
"i.user_id AS user_external_id, u.full_name AS partner_name, "
|
||||
"COALESCE(u.billing_email, u.email) AS partner_email, "
|
||||
"i.created_at AS invoice_date, i.currency, i.status, i.subtotal, i.tax, "
|
||||
"i.amount_paid, i.paid_at "
|
||||
"FROM invoices i JOIN users u ON u.id = i.user_id " + where +
|
||||
" ORDER BY i.created_at", {"since": since})
|
||||
invoices = {str(r["id"]): dict(r, items=[]) for r in cur.fetchall()}
|
||||
if invoices:
|
||||
cur.execute(
|
||||
"SELECT ii.invoice_id, ii.description, ii.quantity, ii.unit_price, ii.amount "
|
||||
"FROM invoice_items ii WHERE ii.invoice_id = ANY(%(ids)s)",
|
||||
{"ids": list(invoices.keys())})
|
||||
for r in cur.fetchall():
|
||||
inv = invoices.get(str(r["invoice_id"]))
|
||||
if inv:
|
||||
inv["items"].append({
|
||||
"description": r["description"], "quantity": r["quantity"],
|
||||
"unit_price": r["unit_price"], "amount": r["amount"]})
|
||||
out = []
|
||||
for inv in invoices.values():
|
||||
inv["id"] = str(inv["id"])
|
||||
inv["user_external_id"] = str(inv["user_external_id"])
|
||||
out.append(inv)
|
||||
return out
|
||||
except psycopg2.Error as e:
|
||||
raise UserError(
|
||||
"Failed reading NexaCloud invoices — the source schema may have changed. "
|
||||
"Underlying error:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ----- ingest side (pure Odoo; unit-tested) ------------------------------
|
||||
@api.model
|
||||
def _ingest_invoices(self, data, post=False):
|
||||
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,
|
||||
"skipped": [], "failed": [], "by_family": {}}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get("id") or "")
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
existing = Move.search(
|
||||
[("x_fc_nexacloud_invoice_id", "=", nc_id)], limit=1)
|
||||
if existing and existing.state != "draft":
|
||||
summary["skipped"].append({"id": nc_id, "reason": "already posted"})
|
||||
continue
|
||||
if existing:
|
||||
existing.invoice_line_ids.unlink() # draft: replace lines
|
||||
move = existing
|
||||
else:
|
||||
move = Move.create({
|
||||
"move_type": "out_invoice",
|
||||
"partner_id": self._fc_partner_for(inv).id,
|
||||
"invoice_date": inv.get("invoice_date"),
|
||||
"ref": inv.get("invoice_number"),
|
||||
"currency_id": cad.id,
|
||||
"x_fc_nexacloud_invoice_id": nc_id,
|
||||
"x_fc_stripe_invoice_id": inv.get("stripe_invoice_id"),
|
||||
})
|
||||
tax = self._fc_tax_for(inv.get("subtotal"), inv.get("tax"))
|
||||
line_vals = []
|
||||
for it in inv.get("items", []):
|
||||
fam = self._fc_family_for(it.get("description"))
|
||||
summary["by_family"][fam] = round(
|
||||
summary["by_family"].get(fam, 0.0) + float(it.get("amount") or 0.0), 2)
|
||||
line_vals.append((0, 0, {
|
||||
"name": it.get("description") or "NexaCloud",
|
||||
"quantity": float(it.get("quantity") or 1.0),
|
||||
"price_unit": float(it.get("unit_price") or it.get("amount") or 0.0),
|
||||
"account_id": self._fc_income_account(fam).id,
|
||||
"tax_ids": [(6, 0, tax.ids)] if tax else [(5, 0, 0)],
|
||||
}))
|
||||
move.write({"invoice_line_ids": line_vals})
|
||||
summary["updated" if existing else "created"] += 1
|
||||
if post:
|
||||
move.action_post()
|
||||
summary["posted"] += 1
|
||||
self._fc_reconcile_payment(move, inv)
|
||||
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)})
|
||||
return summary
|
||||
|
||||
@api.model
|
||||
def _post_ingested(self):
|
||||
moves = self.env["account.move"].search([
|
||||
("x_fc_nexacloud_invoice_id", "!=", False),
|
||||
("state", "=", "draft"), ("move_type", "=", "out_invoice")])
|
||||
posted = 0
|
||||
for mv in moves:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
mv.action_post()
|
||||
posted += 1
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.exception("Ledger post: move %s failed", mv.id)
|
||||
return posted
|
||||
|
||||
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)
|
||||
|
||||
# ----- helpers ------------------------------------------------------------
|
||||
@api.model
|
||||
def _fc_family_for(self, description):
|
||||
d = (description or "").lower()
|
||||
m = re.match(r"remaining time on (.+?)(?: after| from |\s*\()", d)
|
||||
if m:
|
||||
d = m.group(1) # classify proration by the prorated item
|
||||
for fam, kws in self._FAMILY_KEYWORDS:
|
||||
if any(k in d for k in kws):
|
||||
return fam
|
||||
return "other"
|
||||
|
||||
@api.model
|
||||
def _fc_income_account(self, family):
|
||||
Account = self.env["account.account"]
|
||||
# Odoo 19 account codes allow only alphanumerics + dots (no hyphen).
|
||||
code = "NCR." + family.upper()
|
||||
acc = Account.search([("code", "=", code)], limit=1)
|
||||
if not acc:
|
||||
acc = Account.create({
|
||||
"code": code, "name": "NexaCloud %s Revenue" % family.title(),
|
||||
"account_type": "income"})
|
||||
return acc
|
||||
|
||||
@api.model
|
||||
def _fc_tax_for(self, subtotal, tax_amount):
|
||||
"""Map a NexaCloud invoice's (subtotal, tax) to the Odoo sale tax whose computed
|
||||
tax equals it. Picks by effective percent; falls back to a 0% sale tax."""
|
||||
Tax = self.env["account.tax"]
|
||||
sub = float(subtotal or 0.0)
|
||||
amt = float(tax_amount or 0.0)
|
||||
if sub <= 0 or amt <= 0:
|
||||
return Tax.search([("type_tax_use", "=", "sale"), ("amount", "=", 0.0)], limit=1)
|
||||
rate = round(100.0 * amt / sub)
|
||||
tax = Tax.search([("type_tax_use", "=", "sale"), ("amount_type", "=", "percent"),
|
||||
("amount", "=", float(rate))], limit=1)
|
||||
if not tax:
|
||||
tax = Tax.search([("type_tax_use", "=", "sale"), ("name", "ilike", "%s" % rate)], limit=1)
|
||||
return tax
|
||||
|
||||
@api.model
|
||||
def _fc_partner_for(self, inv):
|
||||
"""Resolve the unified partner via the nexacloud account.link (by user id);
|
||||
create partner+link if missing (covers NULL-subscription invoices)."""
|
||||
service = self.env["fusion.billing.service"].search([("code", "=", "nexacloud")], limit=1)
|
||||
if not service:
|
||||
service = self.env["fusion.billing.service"].create(
|
||||
{"name": "NexaCloud", "code": "nexacloud"})
|
||||
link = self.env["fusion.billing.account.link"]._resolve_or_create_partner(
|
||||
service, str(inv.get("user_external_id")),
|
||||
name=inv.get("partner_name"), email=inv.get("partner_email"))
|
||||
return link.partner_id
|
||||
|
||||
@api.model
|
||||
def _fc_stripe_journal(self):
|
||||
Journal = self.env["account.journal"]
|
||||
j = Journal.search([("code", "=", "NCSTR")], limit=1)
|
||||
if not j:
|
||||
j = Journal.create({"name": "NexaCloud Stripe", "code": "NCSTR", "type": "bank"})
|
||||
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":
|
||||
return False
|
||||
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,
|
||||
})
|
||||
reg._create_payments()
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def _fc_prune_metered_shadow(self):
|
||||
"""Delete the superseded metered shadow data (shadow sale.orders, NC-* products,
|
||||
NexaCloud charges, reconciliation rows)."""
|
||||
counts = {}
|
||||
subs = self.env["sale.order"].search([("x_fc_shadow", "=", True)])
|
||||
counts["subscriptions"] = len(subs)
|
||||
subs.unlink()
|
||||
prods = self.env["product.product"].search([("default_code", "=like", "NC-%")])
|
||||
counts["products"] = len(prods)
|
||||
prods.unlink()
|
||||
ch = self.env["fusion.billing.charge"].search([])
|
||||
counts["charges"] = len(ch)
|
||||
ch.unlink()
|
||||
rec = self.env["fusion.billing.reconciliation"].search([])
|
||||
counts["reconciliations"] = len(rec)
|
||||
rec.unlink()
|
||||
return counts
|
||||
Reference in New Issue
Block a user