Compare commits

...

14 Commits

Author SHA1 Message Date
gsinghpal
307afbf3c0 feat(plating): CoC spec-optional + SO-style header + thickness for any cert
- Drop the hard spec_reference gate on fp.certificate.action_issue. The
  customer-facing description (_fp_resolve_customer_facing_description,
  walks job -> SO line, reuses fp_customer_description) now drives the CoC
  Process column; spec_reference prints only when an estimator fills it.
- CoC EN/FR reports swap web.external_layout for fp_external_layout_clean +
  paperformat_fp_a4_portrait. New shared coc_header (company logo + address
  left, Nadcap logo centre, title + Code128 barcode right) mirrors the Sale
  Order header. Removed the 3-logo Nadcap/AS9100/CGP accreditation strip and
  the body H1s; padding-top 0 on both body wrappers.
- Un-gate the Issue Certs wizard thickness upload (was invisible unless the
  customer was thickness-flagged) so a Fischerscope report can be attached to
  ANY cert; merge (page 2) + inline readings already render unconditionally.
- Update issue-gate tests, bump versions (certificates 19.0.9.1.0,
  reports 19.0.11.27.0, jobs 19.0.11.2.0), record CLAUDE.md rule 14c.

Deployed + render-verified on entech (CoC-30065, 223KB PDF, no QWeb errors).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:17:09 -04:00
gsinghpal
fecd2415f6 changes 2026-05-27 19:23:17 -04:00
gsinghpal
e36318f7a5 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>
2026-05-27 18:37:36 -04:00
gsinghpal
feddca19d6 docs(billing): record verified backfill (Stripe+Lago) + go-forward verification caveat 2026-05-27 17:57:27 -04:00
gsinghpal
95378ff1da fix(billing): skip zero-amount invoices (no lines) — drop empty move, don't post nothing 2026-05-27 17:33:36 -04:00
gsinghpal
c8529b8a99 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.
2026-05-27 17:29:41 -04:00
gsinghpal
7a66d7849d fix(billing): name ledger partners by company, not the NexaCloud user's full_name
One operator (e.g. "Gurpreet Singh") manages several distinct customer
businesses; naming partners from full_name mislabeled Mobility Specialties
Inc and Apex Vita Corporation as "Gurpreet Singh". Read the company field,
name the partner by company (mark is_company), and rewrite existing partners
so prior full_name-based names are corrected on re-ingest. 75 tests green.
2026-05-27 17:24:48 -04:00
gsinghpal
9ad09c32b0 fix(billing): robust shadow prune (charges before products + archive fallback) 2026-05-27 17:02:43 -04:00
gsinghpal
6b63df8c3d fix(billing): ledger live-run fixes — UUID cast, UTF-8, reconciling line
Surfaced by the nexamain dry-run against real data:
- reader: cast invoice_items.invoice_id::text (uuid = text[] mismatch).
- readers: set_client_encoding('UTF8') — invoice descriptions contain "×".
- ingest: add a balancing line when invoice.subtotal != sum(items). 9 paid
  base-plan invoices store the charge in subtotal with NO invoice_items, so
  itemized ingestion under-recorded revenue by ~$1,143 (37%); the reconciling
  line makes the Odoo invoice total match what Stripe billed.
74 tests green on odoo-trial.
2026-05-27 16:57:00 -04:00
gsinghpal
72d3130c88 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 '-').
2026-05-27 16:50:31 -04:00
gsinghpal
f6518b4d7e docs(billing): TDD plan for NexaCloud invoice ledger (ingest -> account.move, posted+reconciled+HST) 2026-05-27 16:44:21 -04:00
gsinghpal
bf6ee2bb2c docs(billing): design spec — NexaCloud invoice ledger (Odoo as accounting SoR)
Pivot from recompute-metered-billing to INGEST NexaCloud's real Stripe
invoices into Odoo account.move (posted + payment-reconciled + HST), driven
by the dual-run finding that 94% of NexaCloud revenue is Stripe service
invoices + add-ons + proration outside the per-deployment/CPU model. Full
accounting SoR, all history + ongoing, revenue split by service family,
draft-first rollout. Build/test on trial; reuses the read-only DSN + partner
mapping. Supersedes the metered direction for NexaCloud (engine kept inert).
2026-05-27 16:33:46 -04:00
gsinghpal
077f898283 chnages 2026-05-27 16:12:22 -04:00
gsinghpal
779539d1b5 docs(billing): dual-run stand-up results — shadow import done, reconciliation 2 match / 7 delta (stopped before flip) 2026-05-27 15:57:54 -04:00
31 changed files with 2136 additions and 142 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -77,6 +77,7 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
## Cursor-Managed Modules
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
- **fusion_repairs** — status and deferred work: [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) (bundles 111 shipped at `19.0.2.2.4`; not production-deployed)
## Workflow
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`

View File

@@ -94,6 +94,7 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
## Cursor-Managed Modules
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.2.4`.** Bundles 111 shipped in repo (intake, portals, dashboard, pricing, flowcharts, parts/PO). **Not production-deployed** to Westin as of 2026-05-27. Local: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init`. Outstanding: RingCentral SMS, C2 history sidebar UI, office follow-up crons (config keys only), `tests/`, more flowchart content, sales-rep dashboard tile in `fusion_authorizer_portal`.
## Workflow
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`

View File

@@ -133,6 +133,24 @@ All four chunks of #2 are now built. The brainstorm "which slice" question resol
**NOTE:** Cursor's 7-file WIP remains uncommitted locally and was never deployed — when
Cursor finishes, a normal `./deploy.sh` will ship it (and re-sync `.env`).
**Dual-run stand-up results (2026-05-27) — STOPPED here for review, NOT flipped:**
- Read-only role `odoo_billing_ro` created on nexacloud Postgres (192.168.1.50); DSN set in
`ir.config_parameter` `fusion_billing.nexacloud_dsn` on nexamain. Test Connection OK
(read 7 users / 232 plans / 87 subscriptions).
- **Shadow import committed on nexamain**: 7 partners, 232 plan catalogs, 87 draft shadow
subscriptions; 0 skipped, 0 failed. (NOTE: importer takes ALL plans/subs regardless of
active status → ~464 NC-* products now in the prod ERP catalog. Consider filtering to
`is_active` plans / active subscriptions, or prune the shadow records — all reversible.)
- **Reconciliation pass**: 9 (sub,period) rows had real billing activity → **2 match, 7
delta**, 0 failed. The 7 deltas, MUST resolve before flipping:
1. **One-off / non-subscription invoices** (3 rows: $877.99, $872.66, $32.20) — nexacloud
invoices with NULL subscription_id (fees/manual/credits); not modeled per-subscription.
2. **List-price ≠ actual-invoiced** (4 rows: Odoo $200/$50 vs actual ~$9.1x) — likely
proration or NexaCloud invoicing ≠ plan list price.
- **2d bug surfaced (analysis-only, not safety):** `_reconcile_rows` with an empty
`subscription_external_id` matches NULL-field orders instead of skipping → spurious
delta rows for the one-off invoices. Add `if not sub_ext: skip`.
**Remaining before go-live (gated on infra / ops you do):**
1. Grant the read-only DSN (`fusion_billing.nexacloud_dsn`) — see the module README — then
Test Connection → dry-run import → review → real import.

View 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, ~12 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.

View File

@@ -3,7 +3,7 @@
**Date:** 2026-05-20
**Module:** `fusion_repairs` (new)
**Owner:** Gurpreet
**Status:** Approved (ready for implementation plan)
**Status:** Implemented in repo (bundles 111); see [`fusion_repairs/cloud.md`](../../../fusion_repairs/cloud.md) for shipped vs deferred
**Scope:** Four-phase build (~8-12 weeks); three intake surfaces; 53 features
**Sister modules:** `fusion_repair_compliance`, `fusion_repair_plans`, `fusion_repair_shop`, `fusion_repair_analytics` (Phase 4, optional split)
@@ -26,8 +26,8 @@ Built incrementally across 4 phases; each phase ships a usable slice.
## Current state
- [`fusion_repairs/`](fusion_repairs/) is an **empty folder** — no `__manifest__.py`, models, or views yet.
- No existing code in the repo extends Odoo's `repair` app.
- [`fusion_repairs/`](fusion_repairs/) is a **full Odoo 19 addon** (~100+ files, version `19.0.2.2.4`). Living status: [`fusion_repairs/cloud.md`](../../../fusion_repairs/cloud.md).
- Extends Odoo `repair.order`, `sale.order`, `fusion.technician.task`, portals, and adds flowchart / pricing / parts models.
- Closest precedents:
- [`fusion_ltc_management/models/ltc_repair.py`](fusion_ltc_management/models/ltc_repair.py) — repair workflow + SO + technician task (LTC facilities only; **keep separate**)
- [`fusion_tasks/models/technician_task.py`](fusion_tasks/models/technician_task.py) — field service scheduling with `task_type` including `repair` / `maintenance`

View File

@@ -0,0 +1,158 @@
# NexaCloud → Odoo Invoice Ledger (Design)
- **Date:** 2026-05-27
- **Status:** Design approved (brainstorming) — pending written-spec review
- **Module:** `fusion_centralize_billing` (Odoo 19 Enterprise; build/test on odoo-trial, run on `nexamain`)
- **Supersedes (for NexaCloud):** the metered-billing direction (recompute charges from a CPU-seconds model). The dual-run proved that model captures ~6% of reality.
## 1. Why this exists (the pivot)
The dual-run reconciliation (2026-05-27) showed **94% of NexaCloud's revenue is billed
outside** the per-deployment/CPU-metered model the engine was built for:
| NexaCloud invoices | count | total |
|---|---|---|
| NOT linked to a `subscriptions` row (Hosting services, add-ons) | 22 | **$2,881.08** |
| Linked to a `subscriptions` row (what the metered importer reads) | 7 | **$180.79** |
NexaCloud bills via **Stripe** — service invoices (Odoo ERP Hosting / WordPress Hosting
~$214.50/mo), **add-ons** (Daily Backup, WhatsApp, Forms Builder, White Label), and
**Stripe proration** ("Remaining time on …"). That billing already works. **Re-implementing
Stripe's proration + add-on logic in Odoo is the wrong move.** Instead, Odoo **ingests
NexaCloud's actual invoices** and becomes the single **accounting system of record**
(posted invoices + reconciled payments + HST), while NexaCloud/Stripe keep doing the billing.
## 2. Goal & scope (locked in brainstorming)
- **Full accounting SoR:** posted `account.move` customer invoices, **Stripe payments
reconciled** (invoices show paid, AR accurate), **HST** modelled.
- **All history + ongoing.** Backfill every NexaCloud invoice, then a daily cron for new ones.
- **Revenue split by service family** into distinct income accounts (P&L breakdown).
- **Draft-first rollout:** first nexamain run creates drafts for review, then bulk-post.
## 3. Architecture
A new ingestion component in `fusion_centralize_billing`, mirroring the importer's
read/write split (reuses the read-only DSN + the `account.link` partner mapping already
set up on nexamain):
- **`_read_nexacloud_invoices(since=None)`** — read-only `psycopg2`: `invoices` +
`invoice_items` (+ `users` for partner resolution), optionally since a date. Returns
plain row dicts. The only code touching NexaCloud.
- **`_ingest_invoices(data, post=False)`** — pure Odoo: for each NexaCloud invoice,
upsert one `account.move` (`move_type='out_invoice'`) with lines, tax, and (if paid) a
reconciled payment. Idempotent on `x_fc_nexacloud_invoice_id`. Returns a summary. With
`post=False` invoices are left **draft**; a separate `_post_ingested(...)` bulk-posts
after review.
- Trigger: an **`account.move`-creation wizard/action** + a daily `ir.cron` for ongoing.
## 4. Data mapping
### 4.1 Invoice → `account.move`
- `move_type='out_invoice'`, `partner_id` = unified `res.partner` (resolve `invoice.user_id`
`account.link` (service=nexacloud) → partner; create via the importer's resolver if missing),
`invoice_date` = NexaCloud invoice date, `ref` = `invoice_number`, `currency_id` = CAD.
- New fields (x_fc_*) on `account.move`: `x_fc_nexacloud_invoice_id` (idempotency key, unique),
`x_fc_stripe_invoice_id`.
### 4.2 `invoice_item` → `account.move.line` (one per item)
- `name` = item description, `quantity`, `price_unit`, `account_id` = the **service-family
income account** (see 4.3).
- **Tax:** derive the invoice's effective rate from `invoice.tax / invoice.subtotal`; map to
the matching Odoo `account.tax`**HST 13%** when ≈13%, **no tax** when 0, else the closest
configured tax. Odoo's computed tax must equal NexaCloud's `invoice.tax` (assert in tests).
### 4.3 Service-family → income account (keyword mapping, with fallback)
| Family | Matches (description keywords) |
|---|---|
| **Hosting** | "Odoo ERP Hosting", "WordPress Website Hosting" |
| **Managed plans** | "Managed", "Managed Odoo - Standard", "… - Managed" |
| **Add-ons** | "Daily Backup Protection", "WhatsApp Business Messaging", "Forms Builder", "White Label Branding" |
| **Proration** | "Remaining time on …" → resolve to the family of the named item |
| **Other** (fallback) | anything unmatched → a generic NexaCloud income account (flagged in the summary for review) |
Income-account codes come from the COA (`nexa_coa_setup`); confirm/create at implementation.
### 4.4 Payment reconciliation
- For invoices with `status='paid'` (or `amount_paid >= amount_due`): register an
`account.payment` via a **"NexaCloud Stripe" bank journal**, dated `paid_at`, amount
`amount_paid`, ref = `stripe_invoice_id`; reconcile it against the posted invoice so the
invoice shows **paid** and AR clears.
- Open/unpaid invoices: post (or draft) without a payment → they sit in AR. Void invoices:
ingest as cancelled (or skip) — decide from the data at implementation.
## 5. Idempotency & ongoing sync
- Upsert on `x_fc_nexacloud_invoice_id` (a DB-unique field on `account.move`). Re-running
updates a still-draft move or skips a posted one (never duplicates, never silently mutates
a posted ledger entry — posted invoices that changed upstream are reported for manual review).
- Daily `ir.cron` calls `_read_nexacloud_invoices(since=last_run)``_ingest_invoices(post=True)`
for go-forward invoices (configurable auto-post once trusted).
## 6. Safety & rollout (touches the live ledger)
1. Build + **TDD on odoo-trial** (fixture invoices → assert move totals, tax = source tax,
payment reconciled, idempotency, family→account mapping).
2. **Dry-run** mode (read + report, write nothing) — like the importer.
3. First **nexamain** run: ingest **all history as DRAFT**, report a summary (counts per
family, total $, unmatched-"Other" lines, tax mismatches). **You review a sample.**
4. **Bulk-post** after approval. Then enable the daily cron.
5. **Prune the obsolete metered shadow data** first: delete the 87 draft shadow
`sale.order`s (`x_fc_shadow=True`), the ~464 `NC-*` products, the NexaCloud charges, and
the reconciliation rows — they belong to the superseded recompute approach and would
confuse the ledger.
## 7. Out of scope
- The metered recompute engine's go-live (flip, control loop, usage push) — superseded for
NexaCloud. The engine code stays in the module (potential future metered service, e.g.
NexaMaps) but is inert.
- NexaDesk / NexaMaps ledgers — separate (same ingestion pattern when needed).
- Reproducing Stripe's billing logic — explicitly NOT done; we ingest its output.
## 8. Verify at implementation (Odoo 19; never from memory)
- `account.move` / `account.move.line` / `account.payment` field names + the post flow
(`action_post`) and payment register/reconcile API (read `account` + `account_accountant`
reference on odoo-trial).
- The HST `account.tax` record + income accounts + a usable bank journal on `nexamain`
(from `nexa_coa_setup`); create the "NexaCloud Stripe" journal + family income accounts if absent.
- Whether `invoice_items.amount` is pre-tax (expected: `invoice.subtotal = Σ items`; tax separate).
## 9. Success criteria
- A fixture NexaCloud invoice ingests to a posted `account.move` whose untaxed total, tax
(= source `invoice.tax`), and total match the source; a paid one is reconciled and shows paid.
- Re-running ingests nothing new (idempotent).
- Dry-run on nexamain reports the full backfill (counts per family, $ totals, unmatched lines)
with zero writes; the real run creates drafts; bulk-post on approval.
- Full suite green on odoo-trial (`FCB_EXIT=0`).
## 10. Backfill status + go-forward caveat (2026-05-27)
- **Backfill done + verified on nexamain.** 23 customer invoices posted + payment-reconciled
($3,403.46), 1 void deleted. NexaCloud's `created_at`/`status`/`paid_at` proved
**unreliable** (sync-stamped today; one void marked otherwise), so invoice + payment dates
and paid status were verified against the **source systems**:
- **Stripe** (14 invoices, `in_*` ids) — real `created` / `paid_at` via the Stripe API.
- **Lago** (9 `NEX-*` invoices, `lago:*` ids, billed pre-Stripe) — `issuing_date` +
`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: 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`).

View File

@@ -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.",
@@ -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,

View File

@@ -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>

View File

@@ -7,3 +7,4 @@ from . import webhook
from . import reconciliation
from . import sale_order
from . import res_partner
from . import account_move

View 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.",
)

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
10 access_fusion_billing_charge_acct fusion.billing.charge accountant model_fusion_billing_charge account.group_account_manager 1 1 1 0
11 access_fusion_billing_reconciliation_acct fusion.billing.reconciliation accountant model_fusion_billing_reconciliation account.group_account_manager 1 1 1 0
12 access_fusion_billing_import_wizard fusion.billing.import.wizard model_fusion_billing_import_wizard base.group_system 1 1 1 1
13 access_fc_invoice_ledger_wizard fusion.billing.invoice.ledger.wizard model_fusion_billing_invoice_ledger_wizard base.group_system 1 1 1 1

View File

@@ -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

View File

@@ -0,0 +1,263 @@
# -*- 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
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_unitemized_subtotal_gets_reconciling_line(self):
data = [{
'id': 'inv-base', 'stripe_invoice_id': 'in_base', 'invoice_number': 'NEX-BASE',
'user_external_id': 'u-2', 'partner_name': 'Globex', 'partner_email': 'ops@globex.test',
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'open',
'subtotal': 200.0, 'tax': 0.0, 'amount_paid': 0.0, 'paid_at': None,
'items': [], # base plan billed via Stripe only — no line items
}]
self.W._ingest_invoices(data, post=False)
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-base')])
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_zero_amount_invoice_skipped(self):
data = [{'id': 'inv-zero', 'stripe_invoice_id': 'in_z', 'invoice_number': 'NEX-ZERO',
'user_external_id': 'u-1', 'partner_name': 'Acme', 'partner_email': 'ar@acme.test',
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'paid',
'subtotal': 0.0, 'tax': 0.0, 'amount_paid': 0.0, 'paid_at': None, 'items': []}]
summary = self.W._ingest_invoices(data, post=False)
self.assertFalse(self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-zero')]))
self.assertTrue(any(s.get('reason') == 'zero-amount invoice' for s in summary['skipped']))
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
self.W._ingest_invoices(data, post=False)
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
self.assertEqual(mv.partner_id.name, 'Acme Holdings Inc')
self.assertTrue(mv.partner_id.is_company)
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)
@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)

View 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>

View File

@@ -1 +1,2 @@
from . import import_wizard
from . import invoice_ledger

View File

@@ -116,6 +116,7 @@ class FusionBillingImportWizard(models.TransientModel):
raise UserError("Could not connect to the NexaCloud database: %s" % e)
try:
conn.set_session(readonly=True)
conn.set_client_encoding('UTF8')
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
data = {}
cur.execute(
@@ -161,6 +162,7 @@ class FusionBillingImportWizard(models.TransientModel):
raise UserError("Could not connect to the NexaCloud database: %s" % e)
try:
conn.set_session(readonly=True)
conn.set_client_encoding('UTF8')
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT subscription_id::text AS sub, "

View File

@@ -0,0 +1,498 @@
# -*- 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 datetime, timezone
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)
conn.set_client_encoding('UTF8') # invoice descriptions contain non-ASCII (e.g. "×")
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, "
"u.company AS partner_company, "
"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::text = 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, 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, "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(
[("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
partner = self._fc_partner_for(inv)
if existing:
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_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)],
}))
# Many NexaCloud base-plan invoices store the charge in `subtotal` with
# NO invoice_items. Add a balancing line for any gap so the Odoo invoice
# total matches what Stripe actually billed (captures un-itemized revenue
# and absorbs proration credits where items exceed subtotal).
items_total = round(sum(float(it.get("amount") or 0.0)
for it in inv.get("items", [])), 2)
gap = round(float(inv.get("subtotal") or 0.0) - items_total, 2)
if abs(gap) > 0.01:
summary["by_family"]["base"] = round(
summary["by_family"].get("base", 0.0) + gap, 2)
line_vals.append((0, 0, {
"name": "NexaCloud base/unitemized charge",
"quantity": 1.0, "price_unit": gap,
"account_id": self._fc_income_account("base").id,
"tax_ids": [(6, 0, tax.ids)] if tax else [(5, 0, 0)],
}))
if not line_vals:
# zero-amount invoice (no items, $0 subtotal) — nothing to record;
# drop the empty move (whether just-created or a pre-existing draft).
move.unlink()
summary["skipped"].append({"id": nc_id, "reason": "zero-amount invoice"})
continue
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
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)})
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
@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 or not mv.invoice_line_ids:
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_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
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"})
company = (inv.get("partner_company") or "").strip()
name = company or inv.get("partner_name") or str(inv.get("user_external_id"))
link = self.env["fusion.billing.account.link"]._resolve_or_create_partner(
service, str(inv.get("user_external_id")), name=name, email=inv.get("partner_email"))
partner = link.partner_id
# Name the partner for the BUSINESS (company), not the NexaCloud user's full_name —
# one person (e.g. "Gurpreet Singh") can manage several distinct customer businesses.
# Rewrite an existing partner so earlier full_name-based names get corrected.
if company and (partner.name != company or not partner.is_company):
partner.write({"name": company, "is_company": True})
return partner
@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, 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": payment_date,
"amount": amount,
})
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()
ch = self.env["fusion.billing.charge"].search([]) # before products (charge -> product)
counts["charges"] = len(ch)
ch.unlink()
rec = self.env["fusion.billing.reconciliation"].search([])
counts["reconciliations"] = len(rec)
rec.unlink()
prods = self.env["product.product"].search([("default_code", "=like", "NC-%")])
counts["products"] = len(prods)
try:
prods.unlink()
except Exception: # noqa: BLE001 - undeletable (referenced) products: archive instead
prods.write({"active": False})
counts["products_archived"] = len(prods)
return counts

View File

@@ -384,6 +384,7 @@ Use only: `name`, `model_id`, `state`, `code` (or `function`/`model`), `interval
Applied to `.fp-report table.bordered`, `.fp-landscape table.bordered`, `.fp-report .totals-table`, and `.fp-report .sig-table`. If you need to add a new bordered table, follow the same longhand-border + background-clip template.
14b. **FP report signature source**: every FP report that prints a signer signature (WO Detail, CoC, CoC Chronological, future cert templates) reads from **`res.users.x_fc_signature_image`** — the "Plating Signature" the user uploads under Preferences → My Profile. Retired alternatives (2026-05-17): the HR Employee signature lookup (`user.employee_ids[:1].signature`) and the company-level Signature Override Image (`res.company.x_fc_coc_signature_override`). Both have been removed from all report templates and the company-level setting UI; the override column on `res.company` is kept for now (no migration) but is no longer read. **Don't re-introduce the HR-Employee or override patterns** — pick the signer user via whatever resolution chain the report needs (cert's `certified_by_id`, company's `x_fc_qa_manager_user_ids[:1]`, `job.manager_id`, `company.x_fc_owner_user_id`, etc.) then read `signer_user.x_fc_signature_image`.
14c. **CoC header + spec policy (client request 2026-05-28)**: the formal CoC reports (`report_coc_en` / `report_coc_fr`) render their OWN header (`coc_header` template in `report_coc.xml`) via `fusion_plating_reports.fp_external_layout_clean` — **NOT `web.external_layout`** — bound to `paperformat_fp_a4_portrait`. `coc_header` mirrors the Sale Order header (`report_fp_sale.xml`): company logo+address LEFT, **Nadcap accreditation logo CENTRE** (`company.x_fc_nadcap_logo`), document title + Code128 barcode of `doc.name` RIGHT. The old 3-logo accreditation strip (Nadcap/AS9100/CGP columns) was **removed** from `coc_body`; don't re-add it — Nadcap lives in the header now. The body wrappers (`.fp-coc`, `.fp-coc-chrono`) use `padding-top: 0` (no external_layout band to clear) and no longer carry an `<h1>` (title is in the header, rendered once by the EN/FR wrapper for BOTH body styles). **`spec_reference` is NO LONGER a hard gate** on `fp.certificate.action_issue` — the customer-facing description (`fp.certificate._fp_resolve_customer_facing_description()`, walks `x_fc_job_id → sale_order_id.order_line` matching the part, reuses `sale.order.line.fp_customer_description()`) is the cert's Process/spec text, with `process_description` (recipe name) as fallback; `spec_reference` still prints below when an estimator fills it. The Issue Certs wizard's thickness upload fields are **un-gated** (no longer `invisible="not needs_thickness"`) so a Fischerscope report can be attached to ANY cert and pulled onto the CoC (merge as page 2 for PDFs / inline readings table for .docx/RTF — both already render unconditionally on data presence). NB: the `process_description` / `certified_by_id` / `contact_partner_id`(+email) gates on `action_issue` are SEPARATE and still enforced.
14. **Sticker template — leave the CSS units alone**: `report_fp_wo_sticker_inner` is calibrated for **px units at paperformat dpi=300** on entech's wkhtmltopdf. Do NOT "modernise" it by converting px→mm or by bumping paperformat dpi — both have been tried (2026-05-16) and both collapsed the layout (tiny logo, tiny QR, body grid shorter than the body band, font sizes visually smaller despite using pt). The math suggests the conversions should be equivalent, but wkhtmltopdf's px↔mm↔dpi mapping doesn't follow the obvious model on this image. Trust the working geometry, change only what you came to change. **Barcode size cap**: Odoo core raises `ValueError("Barcode too large")` when `width * height > 1_200_000` OR `max(width, height) > 10000` (see `base/models/ir_actions_report.py::barcode`). Largest safe square is ~1095×1095 — we use 1000×1000 to stay clear of the ceiling. **Em-dash mojibake**: wkhtmltopdf's default font on entech mojibakes em-dash (—), en-dash (), smart quotes, and ellipsis into `â€"` etc. — strip them defensively for any free-text field that bleeds into the sticker (thickness, notes, line.name). The strip pattern is `.replace(u'—', '-').replace(u'', '-')...` in `report_fp_wo_sticker_inner`.
15. **Recipe editor parity**: Step-level UX features (image attachments, prompt editing, settings toggles, preview affordances, etc.) MUST be implemented in BOTH the **Simple Editor** (`fusion_plating/static/src/{js,xml,scss}/simple_recipe_editor.*` + `controllers/simple_recipe_controller.py`) AND the **Tree Editor** (`fusion_plating/static/src/{js,xml,scss}/recipe_tree_editor.*` + `controllers/recipe_controller.py`). Authors choose between editors per-recipe via `preferred_editor`; if a feature only lands in one, half the userbase silently misses it. Default assumption: most clients use the Simple Editor — when in doubt, ship Simple first, then port to Tree in the same change. Backend model + view changes (e.g. new fields on `fusion.plating.process.node`, new tabs on the node form) automatically reach both editors via the related model — only the editor-specific JS/XML/SCSS needs duplicating.
16. **HTTP controller route override = method name must match parent**: To override a route on an inherited controller (e.g. `portal.CustomerPortal.home()` at `/my/home`), the override method MUST share the parent's method name. Declaring a new method name with the same `@http.route()` URL does NOT override — Odoo registers BOTH handlers as siblings and the parent typically wins, silently. Pattern: `class FpCustomerPortal(CustomerPortal): @http.route() def home(self, **kw): ...`. Bit us 2026-05-17 in `fusion_plating_portal/controllers/portal.py``portal_my_home_dashboard()` failed to override stock `home()`; symptom was the rich FP dashboard never rendering at `/my/home` even though the template was active in DB.

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
'version': '19.0.9.0.0',
'version': '19.0.9.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """

View File

@@ -496,16 +496,13 @@ class FpCertificate(models.Model):
and 'x_fc_owner_user_id' in rec.company_id._fields
and rec.company_id.x_fc_owner_user_id):
rec.certified_by_id = rec.company_id.x_fc_owner_user_id
# Spec reference is what the cert ATTESTS — without it the
# cert is just a piece of paper. AS9100 / Nadcap require
# naming the spec the work was performed to.
if not rec.spec_reference:
raise UserError(_(
'Cannot issue certificate "%(name)s" — no Spec '
'Reference set.\n\nFill the Spec Reference field '
'(e.g. "AMS 2404", "MIL-C-26074") so the cert '
'states which standard the work meets.'
) % {'name': rec.name or rec.display_name})
# Spec Reference is OPTIONAL (client request 2026-05-28).
# The customer-facing description now serves as the cert's
# spec / certificate information (see
# _fp_resolve_customer_facing_description + the CoC Process
# column). spec_reference still prints below the description
# when an estimator chooses to fill it, but it no longer
# blocks issuance.
# Process description (what was done to the parts). Without
# it the cert PDF just shows blank process text — customer
# has no idea what they paid for. Auto-filled from the
@@ -733,6 +730,53 @@ class FpCertificate(models.Model):
return
delivery.coc_attachment_id = self.attachment_id.id
def _fp_resolve_customer_facing_description(self):
"""Resolve the customer-facing description used as the cert's
spec / certificate information on the printed CoC.
Client request 2026-05-28: Spec Reference is no longer
mandatory; the customer-facing description (what the estimator
actually typed on the order) now carries the descriptive text.
Resolution — first non-empty wins:
1. The order line matching this cert's job/part — cleaned via
sale.order.line.fp_customer_description() (strips the
"[code] product" prefix Odoo re-prepends).
2. Any product line on the linked sale order.
Returns '' when no order context exists; the report template
then falls back to process_description. All cross-module field
access is guarded so the method stays safe even if the jobs /
configurator layers aren't installed.
"""
self.ensure_one()
job = self.x_fc_job_id if 'x_fc_job_id' in self._fields else False
so = self.sale_order_id or (
job.sale_order_id
if job and 'sale_order_id' in job._fields else False
)
if not so:
return ''
lines = so.order_line.filtered(lambda l: not l.display_type)
if not lines:
return ''
# Prefer the line whose part matches this cert's job.
part = (job.part_catalog_id
if job and 'part_catalog_id' in job._fields else False)
line = self.env['sale.order.line']
if part and 'x_fc_part_catalog_id' in lines._fields:
line = lines.filtered(
lambda l: l.x_fc_part_catalog_id == part
)[:1]
if not line:
line = lines[:1]
if not line:
return ''
if hasattr(line, 'fp_customer_description'):
desc = line.fp_customer_description()
else:
desc = line.name
return (desc or '').strip()
def _fp_render_and_attach_pdf(self):
"""Render the CoC PDF via the bound report action, OPTIONALLY
merge the Fischerscope thickness report PDF (uploaded by the

View File

@@ -55,13 +55,16 @@ class TestActionIssueGates(TransactionCase):
vals.update(kw)
return self.env['fp.certificate'].create(vals)
# ---- the existing gate still works (spec_reference) ----
# ---- spec_reference is OPTIONAL (client request 2026-05-28) ----
def test_blocks_on_missing_spec_reference(self):
def test_no_block_on_missing_spec_reference(self):
"""Spec Reference no longer gates issuance — the customer-facing
description now serves as the cert's spec/certificate info. A
cert with everything else present must issue even with a blank
spec_reference."""
cert = self._make_cert(spec_reference=False)
with self.assertRaises(UserError) as exc:
cert.action_issue()
self.assertIn('Spec Reference', str(exc.exception))
cert.action_issue()
self.assertEqual(cert.state, 'issued')
# ---- new gate: process_description ----
@@ -100,10 +103,12 @@ class TestActionIssueGates(TransactionCase):
cert.action_issue()
self.assertEqual(cert.state, 'issued')
# ---- order: spec_reference still wins (cheapest first) ----
# ---- order: process_description is now the first gate ----
def test_gate_order_spec_reference_first(self):
# Multiple missing → spec_reference message surfaces first.
def test_gate_order_process_description_first(self):
# spec_reference no longer gates, so with everything missing the
# process_description message surfaces first; the (removed) Spec
# Reference message must NOT appear.
cert = self._make_cert(
spec_reference=False,
process_description=False,
@@ -112,9 +117,8 @@ class TestActionIssueGates(TransactionCase):
)
with self.assertRaises(UserError) as exc:
cert.action_issue()
self.assertIn('Spec Reference', str(exc.exception))
# And NOT the process_description message (gate hit first).
self.assertNotIn('Process Description', str(exc.exception))
self.assertIn('Process Description', str(exc.exception))
self.assertNotIn('Spec Reference', str(exc.exception))
# ---- new gate: thickness_report cert needs thickness data ----

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.11.1.0',
'version': '19.0.11.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -48,20 +48,22 @@
<field name="needs_thickness" readonly="1"
widget="boolean_toggle"
string="Needs Thickness"/>
<!-- Upload column. Visible/required only when
the cert needs thickness data. Triggers
the @onchange-driven .docx parser. -->
<!-- Upload column. Available on EVERY cert so a
thickness report can be attached to any CoC
and pulled onto the printed cert (client
request 2026-05-28). needs_thickness still
drives the "Needs Thickness" badge + the
is_ready blocking gate below. Triggers the
@onchange-driven .docx/RTF parser. -->
<field name="fischer_filename" column_invisible="1"/>
<field name="fischer_file"
filename="fischer_filename"
widget="binary"
string="Fischerscope File (PDF or .docx)"
invisible="not needs_thickness"
readonly="not needs_thickness"/>
string="Fischerscope File (PDF or .docx)"/>
<field name="parsed_summary" readonly="1"
string="Parsed"
optional="show"
invisible="not needs_thickness or not parsed_summary"/>
invisible="not parsed_summary"/>
<field name="is_ready" widget="boolean_toggle"
readonly="1"
string="Ready"
@@ -86,15 +88,13 @@
widget="boolean_toggle"/>
</group>
</group>
<group string="Fischerscope File"
invisible="not needs_thickness">
<group string="Fischerscope File">
<field name="fischer_file"
filename="fischer_filename"/>
<field name="fischer_filename"
invisible="1"/>
</group>
<group string="Measurement Image (Optional)"
invisible="not needs_thickness">
<group string="Measurement Image (Optional)">
<field name="fischer_image_file"
filename="fischer_image_filename"
widget="image"
@@ -112,20 +112,17 @@
</group>
<div class="alert alert-info"
role="alert"
invisible="not needs_thickness or not parsed_summary">
invisible="not parsed_summary">
<i class="fa fa-check-circle me-1"/>
<field name="parsed_summary"
readonly="1" nolabel="1"/>
</div>
<separator string="Thickness Readings"
invisible="not needs_thickness"/>
<p class="text-muted small"
invisible="not needs_thickness">
<separator string="Thickness Readings"/>
<p class="text-muted small">
Auto-filled from the .docx upload above.
Edit/add rows manually as needed.
</p>
<field name="reading_line_ids"
invisible="not needs_thickness">
<field name="reading_line_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="nip_mils"/>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.11.26.31',
'version': '19.0.11.27.0',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [

View File

@@ -122,8 +122,10 @@
<!-- ============================================================= -->
<!-- Formal Certificate of Conformance — English -->
<!-- Uses Odoo's default paperformat so web.external_layout's -->
<!-- header/footer band gets its reserved space correctly. -->
<!-- Compact portrait paperformat (margin_top=8) — same as the SO -->
<!-- confirmation. The CoC now renders its own header (coc_header) -->
<!-- via fp_external_layout_clean, so it needs the top of the page, -->
<!-- not Odoo's ~40mm default header reservation. -->
<!-- ============================================================= -->
<record id="action_report_coc_en" model="ir.actions.report">
<field name="name">Certificate of Conformance (English)</field>
@@ -134,6 +136,7 @@
<field name="print_report_name">'CoC EN - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_certificates.model_fp_certificate"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
</record>
<!-- ============================================================= -->
@@ -148,6 +151,7 @@
<field name="print_report_name">'CoC FR - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_certificates.model_fp_certificate"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
</record>
<!-- ============================================================= -->

View File

@@ -22,7 +22,91 @@
<odoo>
<!-- ================================================================== -->
<!-- Shared CoC body — rendered inside web.external_layout -->
<!-- Shared CoC header — logo + Nadcap + title/barcode. Mirrors the -->
<!-- Sale Order header (report_fp_sale.xml fp-sale-header-row): company -->
<!-- logo + address LEFT, Nadcap accreditation logo CENTRE, document -->
<!-- title + Code128 barcode RIGHT. Rendered once by the EN/FR wrappers -->
<!-- above the body, in place of web.external_layout's company band. -->
<!-- ================================================================== -->
<template id="coc_header">
<t t-set="is_fr" t-value="LANG == 'fr'"/>
<t t-set="logo_uri" t-value="('data:image/png;base64,%s' % company.logo.decode()) if company.logo else False"/>
<t t-set="company_fax" t-value="company.partner_id.x_ff_fax_number if 'x_ff_fax_number' in company.partner_id._fields else False"/>
<t t-set="coc_barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name else False"/>
<style>
/* Float-based 3-column header (avoid HTML tables — the global
bordered-table cascade bleeds borders onto nested tables on
entech wkhtmltopdf; see CLAUDE.md). Bottom border separates
header from body, replacing the old hr.heavy. */
.fp-coc-header-row { overflow: hidden; margin-bottom: 10px;
padding-bottom: 6px; border-bottom: 2px solid #000; }
.fp-coc-header-left { float: left; width: 38%; }
.fp-coc-header-mid { float: left; width: 24%; text-align: center; padding-top: 4px; }
.fp-coc-header-right { float: right; width: 38%; text-align: center; }
.fp-coc-logo { max-height: 50px; max-width: 280px; display: block; margin-bottom: 4px; }
.fp-coc-company-addr { font-size: 8.5pt; color: #222; line-height: 1.35; }
.fp-coc-company-addr div { margin: 0; }
.fp-coc-company-addr a { color: #2e6da4; text-decoration: none; }
.fp-coc-nadcap-logo { max-height: 45px; max-width: 115px; display: inline-block; }
.fp-coc-title { font-size: 18pt; font-weight: bold; color: #2e2e2e;
line-height: 1.1; display: block; }
/* Barcode: inline-block wrap so the cert-number label centres
under the bars. Explicit no-border (wkhtmltopdf frames
inline-data imgs on entech). */
.fp-coc-bc-wrap { display: inline-block; text-align: center; margin-top: 4px; }
.fp-coc-bc-wrap img { height: 48px; max-width: 240px; border: 0 !important; padding: 0; display: block; }
.fp-coc-bc-label { font-size: 14pt; font-weight: bold; color: #000;
margin-top: 6px; letter-spacing: 1.2px; }
</style>
<div class="fp-coc-header-row">
<div class="fp-coc-header-left">
<t t-if="logo_uri">
<img t-att-src="logo_uri" class="fp-coc-logo" alt="Logo"/>
</t>
<div class="fp-coc-company-addr">
<div>
<t t-if="company.partner_id.street"><span t-esc="company.partner_id.street"/></t>
<t t-if="company.partner_id.city"> | <span t-esc="company.partner_id.city"/></t>
<t t-if="company.partner_id.state_id"> | <span t-esc="company.partner_id.state_id.code or company.partner_id.state_id.name"/></t>
<t t-if="company.partner_id.zip"> | <span t-esc="company.partner_id.zip"/></t>
</div>
<div t-if="company.phone or company_fax">
<t t-if="company.phone">Tel: <span t-esc="company.phone"/></t>
<t t-if="company.phone and company_fax">&#160;&#160;&#160;</t>
<t t-if="company_fax">Fax: <span t-esc="company_fax"/></t>
</div>
<div t-if="company.partner_id.website">
<a t-att-href="company.partner_id.website"><span t-esc="company.partner_id.website"/></a>
</div>
</div>
</div>
<!-- Centre: NADCAP accreditation logo, base64-inlined from
company settings (wkhtmltopdf can't fetch over HTTP on
entech). Same source as the Sale Order header. -->
<div class="fp-coc-header-mid">
<t t-if="company.x_fc_nadcap_active and company.x_fc_nadcap_logo">
<img class="fp-coc-nadcap-logo"
t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()"
alt="Nadcap Accredited"/>
</t>
</div>
<div class="fp-coc-header-right">
<span class="fp-coc-title">
<t t-if="not is_fr">Certificate of Conformance</t>
<t t-if="is_fr">Certificat de Conformité</t>
</span>
<t t-if="coc_barcode_uri">
<div class="fp-coc-bc-wrap">
<img t-att-src="coc_barcode_uri" alt="Cert Barcode"/>
<div class="fp-coc-bc-label"><span t-field="doc.name"/></div>
</div>
</t>
</div>
</div>
</template>
<!-- ================================================================== -->
<!-- Shared CoC body — rendered inside fp_external_layout_clean -->
<!-- ================================================================== -->
<template id="coc_body">
<t t-set="is_fr" t-value="LANG == 'fr'"/>
@@ -38,18 +122,12 @@
<t t-set="signer_name" t-value="(signer_user and signer_user.name) or ''"/>
<style>
/* padding-top history: original 50mm wasted too much
page-1 space; dropped to 5mm caused the title to
overlap the ENTECH header (the rendered header is
taller than paperformat margin_top reserves). 20mm
is the middle ground — title sits cleanly below the
header, still saves ~30mm vs the original 50mm so the
signature block fits on page 1. If the header logo /
address changes height, bump this in step with
paperformat.margin_top. See CLAUDE.md "wkhtmltopdf
header overlap". */
/* No web.external_layout header band anymore — the CoC
renders its own header (coc_header) above the body, the
same way the Sale Order does. So no top padding is
needed to clear an Odoo header zone. */
.fp-coc { font-family: Arial, sans-serif; font-size: 9pt; color: #000;
padding-top: 20mm; }
padding-top: 0; }
.fp-coc h1 { text-align: center; font-size: 20pt; margin: 0 0 10px 0; font-weight: bold; }
.fp-coc hr.heavy { border: 0; border-top: 2px solid #000; margin: 6px 0; }
.fp-coc table { width: 100%; border-collapse: collapse; margin-bottom: 6px; }
@@ -61,20 +139,6 @@
.fp-coc td { padding: 5px 8px; vertical-align: top; font-size: 8.5pt; }
.fp-coc .text-center { text-align: center; }
.fp-coc .text-end { text-align: right; }
.fp-coc .accreditation-table { margin: 8px 0; }
.fp-coc .accreditation-table td.accreditation-cell {
width: 33.33%;
text-align: center;
vertical-align: middle;
padding: 10px;
border: 1px solid #000;
height: 2.8cm;
}
.fp-coc .accreditation-table img {
max-height: 2cm;
max-width: 95%;
vertical-align: middle;
}
.fp-coc .customer-logo { max-height: 1.8cm; max-width: 3.5cm; }
.fp-coc .cert-statement-box { border: 1px solid #000; padding: 10px; font-size: 8.5pt; }
.fp-coc .cert-statement-box h4 { margin: 0 0 6px 0; font-size: 9.5pt; font-weight: bold; }
@@ -116,40 +180,11 @@
<div class="fp-coc">
<!-- Title -->
<h1 t-if="not is_fr">Certificate of Conformance</h1>
<h1 t-if="is_fr">Certificat de Conformité</h1>
<!-- Accreditations — 3 bordered columns, one logo per column -->
<t t-set="nadcap_on" t-value="company.x_fc_nadcap_active and company.x_fc_nadcap_logo"/>
<t t-set="as9100_on" t-value="company.x_fc_as9100_active and company.x_fc_as9100_logo"/>
<t t-set="cgp_on" t-value="company.x_fc_cgp_active and company.x_fc_cgp_logo"/>
<t t-if="nadcap_on or as9100_on or cgp_on">
<table class="bordered accreditation-table">
<tr>
<td class="accreditation-cell">
<t t-if="nadcap_on">
<img t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()"
alt="Nadcap Accredited"/>
</t>
</td>
<td class="accreditation-cell">
<t t-if="as9100_on">
<img t-att-src="'data:image/png;base64,%s' % company.x_fc_as9100_logo.decode()"
alt="AS9100 / ISO 9001"/>
</t>
</td>
<td class="accreditation-cell">
<t t-if="cgp_on">
<img t-att-src="'data:image/png;base64,%s' % company.x_fc_cgp_logo.decode()"
alt="Controlled Goods Program"/>
</t>
</td>
</tr>
</table>
</t>
<hr class="heavy"/>
<!-- Title + accreditation logos moved to the shared
coc_header (rendered above this body by the EN/FR
wrapper). The 3-logo accreditation strip was dropped
per client request 2026-05-28; the Nadcap logo now
lives in the header, mirroring the Sale Order. -->
<!-- Customer block — 3 columns: address | contact | logo -->
<table class="bordered">
@@ -272,7 +307,13 @@
<tr>
<td class="text-center"><t t-esc="doc.part_number or '-'"/></td>
<td>
<t t-esc="doc.process_description or ''"/>
<!-- Customer-facing description is the cert's
spec / certificate info (client request
2026-05-28). Falls back to the recipe-
derived process_description. spec_reference,
now optional, still prints below when set. -->
<t t-set="cust_desc" t-value="doc._fp_resolve_customer_facing_description()"/>
<t t-esc="cust_desc or doc.process_description or ''"/>
<t t-if="doc.spec_reference">
<br/><em t-esc="doc.spec_reference"/>
</t>
@@ -562,8 +603,14 @@
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-set="company" t-value="(doc.sale_order_id.company_id if doc.sale_order_id else False) or (doc.production_id.company_id if doc.production_id else False) or env.company"/>
<t t-call="web.external_layout">
<!-- Custom SO-style header instead of web.external_layout's
company band. fp_external_layout_clean provides the
.article wrapper Odoo needs for correct UTF-8 dispatch
plus a minimal page-number footer, with NO auto header
div — coc_header renders the visible header instead. -->
<t t-call="fusion_plating_reports.fp_external_layout_clean">
<t t-set="LANG" t-value="'en'"/>
<t t-call="fusion_plating_reports.coc_header"/>
<div class="page">
<!-- Sub 12c — router picks chronological vs classic body -->
<t t-call="fusion_plating_reports.coc_body_router"/>
@@ -580,8 +627,10 @@
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-set="company" t-value="(doc.sale_order_id.company_id if doc.sale_order_id else False) or (doc.production_id.company_id if doc.production_id else False) or env.company"/>
<t t-call="web.external_layout">
<!-- Custom SO-style header (see report_coc_en). -->
<t t-call="fusion_plating_reports.fp_external_layout_clean">
<t t-set="LANG" t-value="'fr'"/>
<t t-call="fusion_plating_reports.coc_header"/>
<div class="page">
<!-- Sub 12c — router picks chronological vs classic body -->
<t t-call="fusion_plating_reports.coc_body_router"/>

View File

@@ -22,8 +22,7 @@
<t t-set="moves" t-value="(job and 'move_ids' in job._fields and job.move_ids.sorted('move_datetime')) or []"/>
<style>
.fp-coc-chrono { font-family: Arial, sans-serif; font-size: 9pt; color: #000; padding-top: 8mm; }
.fp-coc-chrono h1 { text-align: center; font-size: 18pt; margin: 0 0 6px 0; font-weight: bold; }
.fp-coc-chrono { font-family: Arial, sans-serif; font-size: 9pt; color: #000; padding-top: 0; }
.fp-coc-chrono h3 { font-size: 11pt; margin: 8px 0 2px 0; font-weight: bold; }
.fp-coc-chrono .fp-chrono-meta { font-size: 8.5pt; color: #444; margin-bottom: 4px; }
.fp-coc-chrono table.bordered,
@@ -38,7 +37,8 @@
<div class="fp-coc-chrono">
<h1>Certificate of Conformance</h1>
<!-- Title + Nadcap logo render in the shared coc_header above
this body (rendered once by the EN/FR wrapper). -->
<!-- Job header (compact) -->
<table class="bordered">

View File

@@ -4,41 +4,27 @@
{
'name': 'Fusion Repairs',
'version': '19.0.2.2.4',
'version': '19.0.2.2.6',
'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """
Fusion Repairs
==============
Comprehensive repairs and maintenance management for medical equipment retailers
and service providers (hospital beds, wheelchairs, stairlifts, porch lifts,
walkers, mattresses, rollators).
Medical equipment repair intake, dispatch, maintenance, pricing, and client
self-service for Westin / Fusion Central (beds, wheelchairs, stairlifts,
porch lifts, lift chairs, walkers, mattresses, rollators).
Phase 1 - MVP
-------------
- Three intake surfaces sharing one service layer:
* Backend wizard for CS reps on the phone
* Sales rep portal (/my/repair/new) for reps on the road
* Public client self-service portal (/repair) - voicemail ready
- Guided question templates per medical equipment category
- Phone-first partner lookup with duplicate-call detection
- Multi-equipment per call (one repair.order per unit)
- Photo / video capture during intake
- Third-party equipment support (equipment we didn't sell)
- Auto warranty detection from original sale order
- Office notification recipients + 4 follow-up activities
- repair.order extensions linked to fusion.technician.task
Shipped (see fusion_repairs/cloud.md for full status):
- Backend CS wizard + OWL dashboard + repair.order (RO-YYYYMM-NN)
- Sales rep portal (/my/repair/*) and public portal (/repair) with AI self-check
- Maintenance contracts, tokenized booking, reminder crons
- Visit report wizard (signature, parts, Poynt), callout/labor-warranty pricing
- CS troubleshooting flowcharts (Drawflow designer + OWL runner)
- Parts-to-order with draft purchase orders and client ETA emails
Phase 2-4 (roadmap)
-------------------
- AI self-check engine with strict medical safety guardrails
- Upsell engine and direct-buy parts/plans
- Repair warranty tracking (free re-do window)
- Visit report wizard with Poynt terminal payment
- Maintenance contracts with client self-booking
- Weekend safety on-call paging
- SMS notifications, compliance certificates, analytics
Deferred: RingCentral SMS, fusion_schedule slots, office follow-up crons,
equipment portal, mail-in shop workflow, ADP/claims bridge, automated tests.
Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
""",

235
fusion_repairs/cloud.md Normal file
View File

@@ -0,0 +1,235 @@
# Fusion Repairs — Status & Handoff
**Module:** `fusion_repairs`
**Version:** `19.0.2.2.4` (as of 2026-05-27)
**Design spec:** [`docs/superpowers/specs/2026-05-20-fusion-repairs-design.md`](../docs/superpowers/specs/2026-05-20-fusion-repairs-design.md)
**Owner context:** Medical equipment repair intake (beds, wheelchairs, stairlifts, porch lifts, lift chairs, etc.) for Westin Healthcare / Fusion Central.
This file is the **living status tracker** for what shipped in local dev vs what the original four-phase spec still defers. Update it whenever a bundle lands or production deploys.
---
## Deployment status
| Environment | Status | Notes |
|-------------|--------|--------|
| **Local OrbStack** (`odoo-modsdev-app`, DB `fusion-dev` or `modsdev`) | **Installed & upgraded** | Primary dev target. URL typically `http://localhost:8082`. Upgrade: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init` |
| **Production (odoo-westin / erp.westinhealthcare.ca)** | **Not deployed** | Do not upgrade remotely without explicit sign-off after local E2E. |
| **Automated unit tests** | **None in repo** | E2E was done via ad-hoc shell scripts during development; add `tests/` before production. |
After JS/SCSS changes: bump `__manifest__.py` version, upgrade module, flush `/web/assets/%` attachments if the browser serves stale bundles, hard-refresh.
---
## Implementation bundles (shipped in code)
Work was delivered in **11 bundles** on top of the approved design spec. All of the following exist in the `fusion_repairs/` tree unless marked *partial*.
| Bundle | Theme | Highlights |
|--------|--------|------------|
| **1** | CS MVP + permissions | Backend intake wizard, intake templates/questions, `fusion.repair.intake.service`, `repair.order` extensions, RO-`YYYYMM`-`NN` sequence, activities, emails, kanban/menus, admin/office ACLs |
| **2** | Portals + weekend safety | Public `/repair`, sales rep `/my/repair/*`, AI self-check + rules fallback, on-call paging + ack route, honeypot + rate limits |
| **3** | Reminders + maintenance | Maintenance contracts + booking token portal, day-before / NPS / loaner crons, `x_fc_done_at`, dashboard OWL hub |
| **4** | Compliance certs | `fusion.repair.inspection.certificate`, PDF report, expiry cron, visit-report “issue cert” |
| **5** | Service catalog + pricing | `fusion.repair.service.catalog`, variance / requote on visit report |
| **6** | Rush / emergency | `fusion.repair.emergency.charge`, rush fields on intake + repair, squeeze-into-today scheduling |
| **7** | Parts pipeline (basic) | `fusion.repair.part.order`, awaiting-parts emails, visit-report parts-needed path |
| **8** | Mobile tech UX | Maps button, signature pad, found-another-issue, timer on task, Poynt collect on repair |
| **9** | Repair warranty + plans | `fusion.repair.warranty.coverage`, service plan subscriptions from SO confirm |
| **10** | Westin rate card | Callout rates (standard vs lift & elevating), delivery charges, **store labor warranty**, waive labor (manager/sales rep only), in-shop flag |
| **11** | CS flowcharts + vendor PO | Symptom classes, Drawflow designer + OWL runner, demo stairlift chart, draft PO from part order, Wysiwyg node content (`markup()` for HTML) |
---
## Shipped by feature ID (design spec catalogue)
Legend: **Done** = usable in backend/portal; **Partial** = model or cron exists but UX/integration incomplete; **Not started** = no meaningful code.
### CS / call intake
| ID | Feature | Status |
|----|---------|--------|
| C1 | Duplicate-call detection | **Done** — wizard banners + link to open repair |
| C2 | Client history sidebar in wizard | **Partial**`res.partner` prefs + repair count; **no** lazy sidebar UI (last 3 repairs, maintenance, loaner, ADP) |
| C3 | SMS reply from wizard | **Not started** — needs `fusion_ringcentral` |
| C4 | Canned scripts per issue | **Not started** — no `fusion.repair.script.template` |
| C5 | Outstanding-balance warning | **Done** |
| C6 | Quote-only mode | **Done** |
### Dispatcher
| ID | Feature | Status |
|----|---------|--------|
| D1 | Map view of open repairs by zone | **Not started** |
| D2 | Tech skills matrix | **Done**`res.users.x_fc_repair_skills`; dispatch prefers matching category |
| D3 | Parts pre-pull checklist | **Not started** — no nightly picklist cron/email |
| D4 | Reschedule with client SMS approval | **Not started** — needs RingCentral two-way SMS |
### Technician (mobile / in-field)
| ID | Feature | Status |
|----|---------|--------|
| T1 | Open in Maps | **Done** |
| T2 | AI pre-visit brief | **Partial**`x_fc_ai_summary` on repair; not a dedicated mobile “brief” panel |
| T3 | Labour timer via fusion_clock | **Partial** — local Start/Stop timer on `fusion.technician.task`; **not** wired to `fusion_clock` |
| T4 | Client signature | **Done** — visit report wizard |
| T5 | Found another issue | **Done** |
| T6 | Parts replaced — serial capture | **Done** — visit report text field |
| T7 | No-show photo proof | **Done** |
| T8 | Poynt on visit report | **Done**`action_collect_payment` on repair |
### Client experience
| ID | Feature | Status |
|----|---------|--------|
| X1 | “Tech is X min away” SMS | **Not started** |
| X2 | Day-before reminder | **Done** (email cron); SMS portion **not started** |
| X3 | Self-reschedule link | **Not started** — email copy mentions “reply to reschedule” only |
| X4 | Post-visit NPS | **Done** (email cron) |
| X5 | Logged-in equipment portal `/my/equipment` | **Not started** |
### Back-office & management
| ID | Feature | Status |
|----|---------|--------|
| M1 | Inspection certificates | **Done** |
| M2 | ADP/funder bridge to `fusion_claims` | **Not started** |
| M3 | Loaner auto-offer | **Partial** — cron + activity + button; **soft** depends on `fusion_loaners_management` (no hard dep) |
| M4 | Mail-in / shop repairs workflow | **Not started** |
| M5 | Pre-paid service plans | **Done** — subscriptions + burn-down on maintenance/visit |
| M6 | Repair warranty (30/90 day re-do) | **Done**`fusion.repair.warranty.coverage` + intake check |
| M7 | Failure rate analytics | **Partial** — dashboard tiles by product/symptom (90-day); not full FCR-by-tech report |
| M8 | OEM warranty claim filing | **Not started** — serial capture only |
| M9 | Margin per repair | **Partial** — dashboard margin summary; fields on repair depend on revenue/cost population |
### Client self-service portal
| ID | Feature | Status |
|----|---------|--------|
| CL1 | Public `/repair` landing | **Done** |
| CL2 | Phone-first lookup | **Done** — PII-safe jsonrpc |
| CL3 | QR `?sn=` pre-fill | **Done** |
| CL4 | Guided questions (shared service) | **Partial** — backend/sales rep use templates; public form is lighter (category + summary) |
| CL5 | Photo upload | **Done** |
| CL6CL8 | AI self-check + guardrails + resolved branch | **Done** |
| CL9 | Upsell engine (rules model) | **Partial** — deterministic rules + AI; **no** `fusion.repair.upsell.suggestion` admin UI |
| CL10 | Direct-buy parts/plans | **Not started** — thanks-page cards only, no shop checkout |
| CL11 | Save & resume tokenized link | **Not started** |
| CL12 | Smart SMS verify for unknown phones | **Not started** |
| CL13 | reCAPTCHA v3 | **Partial** — honeypot + rate limit; **no** reCAPTCHA |
| CL14 | Privacy / PHI consent | **Done** — checkbox on form |
| CL15 | After-hours + on-call paging | **Done** |
| CL16 | Voicemail RC integration | **Not started** |
| CL17 | QR sticker generator | **Done** — wizard + PDF report |
| CL18CL19 | Knowledge base videos / voice input | **Not started** |
| CL20 | Resolution survey + Google review | **Partial** — NPS email after visit; not wired to “resolved on self-check” path |
### Sales rep portal
| ID | Feature | Status |
|----|---------|--------|
| S1 | `/my/repair/new` intake | **Done** |
| S2 | Dashboard tile on sales rep home | **Not started** — no change in `fusion_authorizer_portal` dashboard |
| S3 | `/my/repairs` list | **Done** |
| S4 | `/my/repair/<id>` detail | **Done** (read-only) |
| S5 | Add note from portal | **Not started** |
| S6 | Mobile photo upload | **Done** |
| S7 | Book maintenance for client | **Not started** |
| S8 | Client history sidebar in portal | **Not started** |
| S9 | Quote-only from portal | **Not started** |
| S10 | State-change notifications to rep | **Not started** |
| S11S12 | Warranty signature / commission view | **Not started** |
### Bundle 11 — CS troubleshooting flowcharts (post-spec)
| Item | Status |
|------|--------|
| `fusion.repair.symptom.class` + seed data (stairlift-heavy) | **Done** |
| `fusion.repair.flowchart` + nodes/edges + Drawflow designer | **Done** |
| OWL runner (card mode + “show whole tree”) | **Done** |
| Outcomes: resolved / escalate / order_part | **Done** — transcript → chatter + `internal_notes` |
| Demo chart “Stairlift - Not Moving” | **Done** (one chart; other categories need admin-built charts) |
| Rich text (Wysiwyg) + HTML source toggle in designer | **Done** |
| Part order → vendor, OEM #, cost, ETA, factory refs, draft PO | **Done** |
| Client email on order (ETA only, no cost) | **Done** |
| Office activity on ETA | **Done** |
| “Start Troubleshooting” on `repair.order` | **Done** |
| Troubleshooting **during** intake wizard (before RO exists) | **Not started** — run starts after repair created |
| Tech mobile “what CS tried” panel | **Partial** — via `internal_notes` / chatter, not dedicated task UI |
| Flowcharts for all product categories | **Not started** — content/ops task |
---
## Key models & entry points
| Model / surface | Purpose |
|-----------------|--------|
| `fusion.repair.intake.service` | Single write path for backend wizard, sales rep portal, client portal |
| `fusion.repair.intake.wizard` | CS “New Service Call” |
| `repair.order` | Core record; Fusion fields prefixed `x_fc_*` |
| `fusion.technician.task` | Dispatch (via `fusion_tasks`) |
| `fusion.repair.flowchart` (+ run/step) | Guided CS troubleshooting |
| `fusion.repair.part.order` | Parts + draft `purchase.order` |
| `fusion.repair.callout.rate` / `labor.warranty` / `delivery.charge` | Westin pricing |
| OWL `fusion_repairs.dashboard` | App home KPIs |
| Public routes | `/repair`, `/repair/new`, `/repair/self_check`, `/repair/on-call/ack/<token>` |
| Maintenance | `/repairs/maintenance/book/<token>` |
**Dependencies:** `repair`, `sale_management`, `stock`, `purchase`, `maintenance`, `portal`, `website`, `html_editor`, `fusion_tasks`, `fusion_poynt`, `fusion_authorizer_portal`.
**Soft / optional (not in manifest):** `fusion_ringcentral`, `fusion_schedule`, `fusion_loaners_management`, `fusion_claims`, `fusion_shipping`, `fusion_canada_post`, `fusion.api.service` (OpenAI).
---
## Crons that exist vs config-only
| Cron | Status |
|------|--------|
| Maintenance due reminders (30/7/1 d) | **Done** |
| On-call escalate unacknowledged pages | **Done** |
| Day-before visit reminder (email) | **Done** |
| Post-visit NPS email | **Done** |
| Inspection certificate expiry | **Done** |
| Loaner offer for long repairs | **Done** |
| Office follow-ups (maintenance unbooked, no tech, overdue visit, unpaid invoice) | **Not started**`ir.config_parameter` toggles exist in `data/ir_config_parameter_data.xml` but **no** `ir.cron` or Python methods |
---
## Known gaps & ops follow-ups
1. **Content:** Build and publish flowcharts per (equipment category × symptom), not only the seeded stairlift demo.
2. **C2 / S8:** Add lazy RPC + sidebar UI for last repairs, maintenance status, balance, loaner flag.
3. **RingCentral:** C3, X1, X2 SMS, D4, CL12, CL16 all blocked on `fusion_ringcentral`.
4. **Tests:** Add `fusion_repairs/tests/` TransactionCase coverage before production.
5. **Production deploy:** Copy addon to Westin host, `-u fusion_repairs`, verify rate card numbers against live printed card, train CS on flowchart designer.
6. **Manifest description:** Still listed “Phase 24 roadmap” in `__manifest__.py` — updated in same commit as this file to reflect shipped scope.
---
## Local dev commands
```bash
# Upgrade module
docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init
# Run tests (when tests/ exists) — ephemeral ports required
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs \
-u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
# Force asset rebundle after JS/CSS change (if hash stuck)
docker exec odoo-modsdev-app psql -U odoo -d fusion-dev -c \
"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';"
```
---
## Change log (handoff notes)
| Date | Note |
|------|------|
| 2026-05-27 | Created `cloud.md`. Bundles 111 in repo at `19.0.2.2.4`. Local OrbStack verified; production not deployed. Fixed prior session gap: file had been planned but never written. |
| 2026-05-27 | Backend blank page fixes: `fusion_clock` missing table (separate module); flowchart designer OWL hooks inside `setup()`; Wysiwyg content requires `markup()`. |
---
*When you ship to production or close a spec item, update the tables above and bump the version line at the top.*

View File

@@ -114,6 +114,17 @@ export class FlowchartDesigner extends Component {
return {
content: rawHtml ? markup(rawHtml) : markup(""),
Plugins: MAIN_PLUGINS,
// ChatGPTPlugin (and other plugins shipped in MAIN_PLUGINS on
// Enterprise) call config.getRecordInfo() during init AND destroy
// to associate the editor with a record. We're a standalone
// client action, so we return the currently-selected node's
// identity. Without this, ChatGPTPlugin.destroy throws
// "this.config.getRecordInfo is not a function" every time the
// editor unmounts (node switch, view close, navigate away).
getRecordInfo: () => ({
resModel: "fusion.repair.flowchart.node",
resId: this.state.selectedNodeId || false,
}),
// onChange fires after each edit step - read the current HTML
// from the editor and persist it to selectedMeta + dirty flag.
onChange: () => {
@@ -197,7 +208,13 @@ export class FlowchartDesigner extends Component {
// Drawflow: addNode(name, inputs, outputs, posx, posy, class, data, html)
const outputs = n.node_type === "outcome" ? 0 : 1;
const inputs = n.is_start ? 0 : 1;
const cssClass = `fr-node fr-node-${n.node_type} ${n.is_start ? "fr-node-start" : ""}`;
// Build the class string from an array so non-start nodes don't
// emit a trailing space - Drawflow forwards this through
// classList.add(), which throws "The token provided must not be
// empty" on any empty token.
const cssClass = ["fr-node", `fr-node-${n.node_type}`, n.is_start && "fr-node-start"]
.filter(Boolean)
.join(" ");
const dfId = this.editor.addNode(
clientId,
inputs, outputs,