docs(billing): TDD plan for NexaCloud invoice ledger (ingest -> account.move, posted+reconciled+HST)
This commit is contained in:
637
docs/superpowers/plans/2026-05-27-nexacloud-invoice-ledger.md
Normal file
637
docs/superpowers/plans/2026-05-27-nexacloud-invoice-ledger.md
Normal file
@@ -0,0 +1,637 @@
|
||||
# NexaCloud → Odoo Invoice Ledger — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Ingest NexaCloud's real (Stripe-billed) invoices into Odoo as posted `account.move` customer invoices with reconciled payments + HST, so Odoo is the accounting system of record — all history + ongoing, revenue split by service family, draft-first on the live books.
|
||||
|
||||
**Architecture:** A new ingester in `fusion_centralize_billing` mirroring the importer's read/write split: `_read_nexacloud_invoices` (read-only psycopg2 via the existing DSN) → `_ingest_invoices` (pure Odoo: create `account.move` drafts idempotently, map lines to per-family income accounts, derive tax, reconcile Stripe payments) → `_post_ingested` (bulk-post after review). Reuses the `account.link` partner mapping. Native Odoo accounting does the rest.
|
||||
|
||||
**Tech Stack:** Odoo 19 Enterprise, `account_accountant`, `psycopg2`. Tests: `TransactionCase` on odoo-trial (`bash scripts/fcb_test_on_trial.sh`, pass = `FCB_EXIT=0`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
- **Never code accounting internals from memory** (CLAUDE rule #1). Reference confirmed on trial: `account.move` has `invoice_line_ids`/`invoice_date`/`action_post`; `account.payment.register` exists; `account_type='income'`/`'asset_receivable'` valid; sale taxes are Canadian (find HST 13% by `amount=13` / name). Where a step says "read reference", confirm before relying on it.
|
||||
- **Models, not UI:** logic in model methods; the wizard only calls them. Testable under `TransactionCase`.
|
||||
- **New fields on native models:** `x_fc_*`. Declarative `models.Constraint` only.
|
||||
- Tests run on **odoo-trial** (`bash scripts/fcb_test_on_trial.sh`, full suite, ~1–2 min). Register each new `tests/test_*.py` in `tests/__init__.py` in the same task.
|
||||
|
||||
## File structure
|
||||
```
|
||||
fusion_centralize_billing/
|
||||
models/
|
||||
account_move.py # NEW: account.move inherit (x_fc_nexacloud_invoice_id, x_fc_stripe_invoice_id)
|
||||
__init__.py # + account_move
|
||||
wizards/
|
||||
invoice_ledger.py # NEW: the ingester (read + ingest + post + family/tax/payment helpers)
|
||||
__init__.py # + invoice_ledger
|
||||
views/
|
||||
invoice_ledger_views.xml # NEW: wizard form + action + menu + cron
|
||||
security/ir.model.access.csv # + ledger wizard ACL
|
||||
__manifest__.py # + views/invoice_ledger_views.xml
|
||||
tests/
|
||||
test_invoice_ledger.py # NEW
|
||||
__init__.py # + test_invoice_ledger
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Scaffold — account.move fields + ledger wizard skeleton
|
||||
|
||||
**Files:** create `models/account_move.py`, `wizards/invoice_ledger.py`, `views/invoice_ledger_views.xml`; modify `models/__init__.py`, `wizards/__init__.py`, `security/ir.model.access.csv`, `__manifest__.py`.
|
||||
|
||||
- [ ] **Step 1: account.move inherit** — `models/account_move.py`:
|
||||
```python
|
||||
# -*- 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.")
|
||||
```
|
||||
Add `from . import account_move` to `models/__init__.py`.
|
||||
|
||||
- [ ] **Step 2: ledger wizard skeleton** — `wizards/invoice_ledger.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
import json
|
||||
import logging
|
||||
|
||||
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)
|
||||
|
||||
def _ingest_invoices(self, data, post=False):
|
||||
return {"created": 0, "updated": 0, "posted": 0, "skipped": [], "failed": [], "by_family": {}}
|
||||
```
|
||||
Add `from . import invoice_ledger` to `wizards/__init__.py`.
|
||||
|
||||
- [ ] **Step 3: view + action + menu** — `views/invoice_ledger_views.xml`:
|
||||
```xml
|
||||
<?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"/>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: security + manifest** — append to `security/ir.model.access.csv`:
|
||||
```
|
||||
access_fc_invoice_ledger_wizard,fusion.billing.invoice.ledger.wizard,model_fusion_billing_invoice_ledger_wizard,base.group_system,1,1,1,1
|
||||
```
|
||||
Add `"views/invoice_ledger_views.xml"` to `__manifest__.py` `data`.
|
||||
|
||||
- [ ] **Step 5: verify upgrade** — `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0` (existing tests pass; new model/fields/view load).
|
||||
|
||||
- [ ] **Step 6: commit** — `feat(billing): invoice-ledger scaffold (account.move x_fc fields + wizard)`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Service-family classification + income account
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`; create `tests/test_invoice_ledger.py` (+ register in `tests/__init__.py`).
|
||||
|
||||
- [ ] **Step 1: failing test** — `tests/test_invoice_ledger.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@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) # split by family
|
||||
self.assertEqual(self.W._fc_income_account('hosting'), a_host) # idempotent
|
||||
```
|
||||
Append `from . import test_invoice_ledger` to `tests/__init__.py`.
|
||||
|
||||
- [ ] **Step 2: run** → FAIL (`_fc_family_for` missing).
|
||||
|
||||
- [ ] **Step 3: implement** — in `wizards/invoice_ledger.py`:
|
||||
```python
|
||||
_FAMILY_KEYWORDS = [
|
||||
('hosting', ['odoo erp hosting', 'wordpress website hosting']),
|
||||
('managed', ['managed']),
|
||||
('addons', ['daily backup', 'whatsapp', 'forms builder', 'white label']),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _fc_family_for(self, description):
|
||||
import re
|
||||
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']
|
||||
code = 'NCR-' + family.upper()[:6]
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If `account.account.create` needs more required fields on this build, read `account_account.py` on trial and add them — don't weaken the test.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): ledger service-family classification + per-family income accounts`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Tax derivation (match NexaCloud's invoice.tax)
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append):
|
||||
```python
|
||||
@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')
|
||||
# the chosen tax computes 13.00 on 100.00
|
||||
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)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement**:
|
||||
```python
|
||||
@api.model
|
||||
def _fc_tax_for(self, subtotal, tax_amount):
|
||||
"""Map a NexaCloud invoice's (subtotal, tax_amount) 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)
|
||||
tax_amt = float(tax_amount or 0.0)
|
||||
if sub <= 0 or tax_amt <= 0:
|
||||
return Tax.search([('type_tax_use', '=', 'sale'), ('amount', '=', 0.0)], limit=1)
|
||||
rate = round(100.0 * tax_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
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (Read reference if no 13% sale tax exists: `docker exec odoo-trial-app ... grep -i hst` the l10n_ca data; on nexamain confirm the HST 13% record from `nexa_coa_setup`.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): ledger tax derivation matching source invoice tax`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Ingest invoices → draft account.move (idempotent)
|
||||
|
||||
**Read reference first:**
|
||||
```bash
|
||||
ssh pve-worker1 "qm guest exec 316 -- bash -lc 'docker exec odoo-trial-app bash -lc \"grep -nE \\\"def action_post|invoice_line_ids|move_type\\\" /mnt/enterprise-addons/account_accountant/../account/models/account_move.py | head\"'"
|
||||
```
|
||||
Confirm `account.move.create({'move_type':'out_invoice','partner_id':..,'invoice_line_ids':[(0,0,{'name','quantity','price_unit','account_id','tax_ids'})]})` and `move.amount_untaxed/amount_tax/amount_total`.
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append) — uses a fixture invoice dict shaped like `_read_nexacloud_invoices` output:
|
||||
```python
|
||||
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 TestLedgerIngest(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
self.svc = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'NexaCloud', 'code': 'nexacloud'})
|
||||
|
||||
def test_ingest_creates_draft_invoice_with_right_totals(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
mv = self.env['account.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')
|
||||
line = mv.invoice_line_ids
|
||||
self.assertEqual(line.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.env['account.move'].search_count(
|
||||
[('x_fc_nexacloud_invoice_id', '=', 'inv-1')]), 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement** the partner resolver + `_ingest_invoices`:
|
||||
```python
|
||||
@api.model
|
||||
def _fc_partner_for(self, inv):
|
||||
"""Resolve the unified partner for an invoice via the nexacloud account.link
|
||||
(by user_external_id); create partner+link if missing (covers NULL-subscription
|
||||
invoices, which still carry a user)."""
|
||||
service = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')], limit=1)
|
||||
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 _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:
|
||||
if existing.state != 'draft':
|
||||
summary['skipped'].append({'id': nc_id, 'reason': 'already posted'})
|
||||
continue
|
||||
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 _fc_reconcile_payment(self, move, inv):
|
||||
"""Placeholder until Task 5; defined so post=True doesn't AttributeError."""
|
||||
return False
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If tax computes to 13.00 only when the company/fiscal position allows it, read the tax setup on trial; if `amount_tax` ≠ 13.00, the chosen tax is wrong — fix `_fc_tax_for`, never weaken the assertion.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): ingest NexaCloud invoices -> draft account.move (idempotent)`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Reconcile Stripe payments (paid invoices show paid)
|
||||
|
||||
**Read reference first:** confirm the payment-register flow on trial:
|
||||
```bash
|
||||
ssh pve-worker1 "qm guest exec 316 -- bash -lc 'docker exec odoo-trial-app bash -lc \"grep -nE \\\"_create_payments|def action_create_payments\\\" /mnt/enterprise-addons/account/wizard/account_payment_register.py | head\"'"
|
||||
```
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append):
|
||||
```python
|
||||
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.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertIn(mv.payment_state, ('paid', 'in_payment'))
|
||||
```
|
||||
(Add this inside `TestLedgerIngest`.)
|
||||
|
||||
- [ ] **Step 2: run** → FAIL (payment not reconciled).
|
||||
|
||||
- [ ] **Step 3: implement** `_fc_reconcile_payment` + a journal helper (replace the placeholder):
|
||||
```python
|
||||
@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
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If `payment_state` is `in_payment` rather than `paid`, that's expected when the bank journal isn't reconciled to a statement — accept both, as the assertion does.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): reconcile Stripe payments so ingested invoices show paid`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Reader + wizard actions + bulk-post + cron
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `views/invoice_ledger_views.xml`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** for bulk-post + DSN guard (append):
|
||||
```python
|
||||
def test_post_ingested_posts_drafts(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
n = self.W._post_ingested()
|
||||
mv = self.env['account.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):
|
||||
from odoo.exceptions import UserError
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||
with self.assertRaises(UserError):
|
||||
self.W._read_nexacloud_invoices()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement** `_post_ingested`, `_read_nexacloud_invoices`, `action_run`, and a cron entry:
|
||||
```python
|
||||
@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 as e: # noqa: BLE001
|
||||
_logger.exception("Ledger post: move %s failed", mv.id)
|
||||
return posted
|
||||
|
||||
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()}
|
||||
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']})
|
||||
for inv in invoices.values():
|
||||
inv['id'] = str(inv['id'])
|
||||
inv['user_external_id'] = str(inv['user_external_id'])
|
||||
return list(invoices.values())
|
||||
except psycopg2.Error as e:
|
||||
raise UserError("Failed reading NexaCloud invoices — schema may have changed:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
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"}
|
||||
```
|
||||
Add a daily cron to `views/invoice_ledger_views.xml`:
|
||||
```xml
|
||||
<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>
|
||||
```
|
||||
And `_cron_ingest_recent` (ingest invoices from the last 2 days, idempotent):
|
||||
```python
|
||||
def _cron_ingest_recent(self):
|
||||
from datetime import timedelta
|
||||
since = fields.Datetime.to_string(fields.Datetime.now() - timedelta(days=2))
|
||||
return self._ingest_invoices(self._read_nexacloud_invoices(since=since), post=True)
|
||||
```
|
||||
(Cron ships `active=False` — enabled only after the backfill is reviewed.)
|
||||
|
||||
- [ ] **Step 4: run** → PASS.
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): invoice-ledger reader, wizard actions, bulk-post, daily cron`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Prune obsolete metered shadow data
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append):
|
||||
```python
|
||||
def test_prune_shadow_removes_shadow_subs_only(self):
|
||||
# a shadow sub + a normal order
|
||||
p = self.env['res.partner'].sudo().create({'name': 'X'})
|
||||
shadow = self.env['sale.order'].sudo().create({'partner_id': p.id, 'x_fc_shadow': True})
|
||||
n = self.W._fc_prune_metered_shadow()
|
||||
self.assertFalse(shadow.exists())
|
||||
self.assertGreaterEqual(n.get('subscriptions', 0), 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement**:
|
||||
```python
|
||||
@api.model
|
||||
def _fc_prune_metered_shadow(self):
|
||||
"""Delete the superseded metered shadow data (shadow sale.orders, NC-* products,
|
||||
NexaCloud charges, reconciliation rows). Reversible only by re-import."""
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If a product can't unlink due to references, archive instead — read the error and adjust.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): prune obsolete metered shadow data helper`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full suite + static checks
|
||||
|
||||
- [ ] `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0`.
|
||||
- [ ] `grep -rn "_sql_constraints" fusion_centralize_billing/ || echo clean` → clean.
|
||||
- [ ] `grep -rnE "sale\.subscription[^.]" fusion_centralize_billing/ | grep -v "sale.subscription.plan"` → only docstring.
|
||||
- [ ] commit any fixes.
|
||||
|
||||
## Done = invoice ledger ready to run
|
||||
|
||||
Then (separate, gated, NOT in this plan): on nexamain — prune shadow data, **dry-run** the full backfill (review the per-family $ summary + unmatched "Other" lines), ingest **as draft**, you review a sample, **bulk-post**, enable the daily cron.
|
||||
Reference in New Issue
Block a user