Compare commits

...

7 Commits

Author SHA1 Message Date
gsinghpal
76866a7c76 fix(fusion_helpdesk_central): wizard dialog closed on Generate Summary click
Previous fix (return True from action_generate_summary) prevented the
self-id crash but introduced a worse regression: Odoo's web client
auto-closes target='new' modals on any non-action return — the
"wizard done" convention. So Generate Summary updated the field and
then immediately killed the dialog, leaving the user with no chance
to click Send Engagement.

The only Odoo-19 idiom that reliably keeps a wizard dialog open
across a button click is to return an act_window dict that re-opens
the same wizard record (res_id=self.id). That was the original
approach — it crashed because of the active_id self-id collision in
default_get. With the active_model='helpdesk.ticket' guard now in
place (from 0104e877), the re-open is safe.

Belt-and-suspenders: the re-open action passes context={} explicitly,
so even if a future change to default_get drops the active_model
guard, there's no parent-form active_id leaking in to confuse the
ticket lookup. The wizard record is loaded by res_id directly; Odoo
19 doesn't call default_get for record loads, only for new-record
creation.

Centralised the re-open logic in _reopen_action so single + bulk
modes share the same code path.

Bumps fusion_helpdesk_central to 19.0.2.3.4.
2026-05-27 15:12:33 -04:00
gsinghpal
f19ca02e05 docs(billing): record odoo-nexa deploy (installed, inert); NexaCloud deploy blocked on Cursor WIP 2026-05-27 15:12:16 -04:00
gsinghpal
1f5eaf0386 docs(billing): handoff update — sub-project #2 complete (2a/2d shipped, 2b/2c code-complete) 2026-05-27 14:52:28 -04:00
gsinghpal
a82f09ea50 fix(billing): reconciliation review fixes — per-subscription key, IDOR guard
- CRITICAL: reconciliation upsert keyed on (service, partner, period) collided
  when one customer has two deployments (two subs) in a period — the second
  overwrote the first. Add external_subscription_id to the model + a
  UNIQUE(service_id, external_subscription_id, period) constraint, and key the
  upsert per subscription. New test proves two subs for one partner keep two rows.
- raise a clear error if the nexacloud service is missing (was a confusing
  per-row failure).
- _fc_resolve_subscription: the integer fallback no longer reaches a different
  service's tagged subscription (latent multi-service IDOR); live untagged subs
  stay resolvable and the partner-link authz is unchanged.
Full suite green on odoo-trial.
2026-05-27 14:51:43 -04:00
gsinghpal
a5144a925c feat(billing): /usage resolves subscription by source app id (enables 2b)
_api_record_usage now resolves the target subscription via the source
app's own id (x_fc_nexacloud_subscription_id, scoped to the service)
before falling back to a direct Odoo sale.order id. This is what lets
NexaCloud push usage against the shadow subscriptions the importer
created from NexaCloud UUIDs — closing the flip-day mapping gap the
review flagged. Authz unchanged (partner must be linked to the service).
2026-05-27 14:37:30 -04:00
gsinghpal
2bdf4ef6a0 feat(billing): 2d dual-run reconciliation (Odoo-computed vs NexaCloud-actual)
fusion.billing.reconciliation gains the compute: _compute_reconciliation
(flat + charge overage vs external, status match/delta at a tolerance) and
_reconcile_rows (resolve shadow sub -> flat + charge, upsert one row per
service/partner/period, per-row isolated). The wizard gains a read-only
_read_reconciliation_rows (NexaCloud usage cpu_hours*3600 + invoice-item
subtotals per YYYY-MM) and a "Run Reconciliation" button. 2a amended to
stamp x_fc_nexacloud_plan_id on shadow subs so reconciliation can find the
charge. Read-only on NexaCloud; writes only reconciliation rows (shadow
guarantees intact). 8 new tests, full suite green on odoo-trial.
2026-05-27 14:34:23 -04:00
gsinghpal
3ba9f2821e docs(billing): spec + TDD plan for 2d NexaCloud dual-run reconciliation 2026-05-27 14:31:26 -04:00
13 changed files with 808 additions and 20 deletions

View File

@@ -78,3 +78,64 @@ start with?**
Cut each new feature branch from `main`, and land it before starting the next. For any
cross-branch git surgery, use a **throwaway `git worktree`** — never switch the shared
working dir's branch, because a concurrent session may be working on it.
---
## UPDATE — sub-project #2 complete (2026-05-27, later session)
All four chunks of #2 are now built. The brainstorm "which slice" question resolved to
2a-first; everything else followed.
**Done + on `main` in `Odoo-Modules` (fully tested on odoo-trial, suite `FCB_EXIT=0`):**
- **2a — importer** (`fusion.billing.import.wizard`): read-only `psycopg2` reader split
from pure-Odoo writes; users→partners+links, plans→`cpu_seconds` charge catalog
(`plan_id` NULL), deployments→one **draft shadow** `sale.order` each with the flat price.
Shadow-safe by construction (draft + no token + NULL `plan_id`). Idempotent, dry-run,
Test-Connection guard, README runbook.
- **2d — reconciliation** (`fusion.billing.reconciliation`): `_compute_reconciliation` +
`_reconcile_rows` (Odoo flat+overage vs NexaCloud actual, status match/delta), reader for
NexaCloud usage+invoice actuals, "Run Reconciliation" button. **Upsert key is
`(service, external_subscription_id, period)`** — per subscription, so a customer with
two deployments doesn't collide.
- **/usage enabler**: `_api_record_usage` resolves a subscription by the source app's own
id (`x_fc_nexacloud_subscription_id`) so NexaCloud can push against shadow subs.
- Core-engine bug fixed in passing: `charge.price_per_unit` is now `Float(16,6)` and
`_compute_billable` keeps 6-dp precision (was `Monetary`/cent-rounded → would under-bill
sub-cent rates and drift from NexaCloud's 4-dp amounts).
**Code-complete in `Nexa-Cloud` (feature-flagged, NOT deployed, NOT integration-tested):**
- **2b — usage push**: `services/odoo_billing_client.py` + a hook in `usage_metering.py`
posting cpu-seconds to Odoo `/usage`. **2c — control loop**:
`routers/odoo_billing.py` (`POST /api/v1/billing/webhooks/central`, HMAC-verified) +
`services/odoo_billing_integration.py` (suspend/restore/deprovision). All INERT unless
`ODOO_BILLING_ENABLED`. Implemented as NEW modules + edits to clean files only —
NexaCloud `main` had concurrent **Cursor uncommitted WIP** (`routers/billing.py`,
`scheduler.py`, `stripe_service.py`, `models/billing.py`, …) which was deliberately not
touched. Commits: `94542ec` + `956abb2` (only my files staged).
**Deployment status (2026-05-27):**
- **odoo-nexa (production `nexamain`): DEPLOYED** — `fusion_centralize_billing` (core + 2a
+ 2d) **fresh-installed** (#1 had never actually been deployed here; `DIR_ABSENT` before).
`ir_module_module.state = installed`, `odoo-nexa-app` healthy. **INERT**: no
`nexacloud_dsn`, all charges `plan_id` NULL (rating cron no-op), no webhooks queued
(dispatch cron no-op), inbound API 401s with no key configured. Synced to
`/opt/odoo/custom-addons` + `-i` via the restart-safe recipe.
- **NexaCloud: NOT deployed (deliberately).** `deploy.sh` does `rsync --delete` of the
**local working tree** then `--no-cache` rebuild — it would ship the concurrent
**uncommitted Cursor WIP** (7 files) to prod. My 2b/2c is also feature-flagged OFF and
the app wasn't boot-tested. Deploy only AFTER: Cursor's WIP is committed/finished, the
app boots clean, and `ODOO_BILLING_*` config is set (otherwise it's inert anyway).
**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.
2. Run a dual-run cycle (Run Reconciliation), confirm all rows `match`.
3. **2c needs the Odoo side to actually EMIT** `invoice.payment_failed` /
`payment_succeeded` / `subscription.terminated` webhooks with `deployment_id` in the
payload — that emission isn't wired yet (it belongs to the live billing flow). The
NexaCloud receiver is built to that contract; confirm the payload shape when wiring it.
4. Integration-test + deploy the NexaCloud changes (no test harness in that repo).
5. The flip: set `charge.plan_id`, attach Stripe tokens, confirm the shadow subs.
Specs/plans: `specs/2026-05-27-nexacloud-billing-importer-design.md`,
`specs/2026-05-27-nexacloud-reconciliation-design.md`, and the matching plans.

View File

@@ -0,0 +1,288 @@
# NexaCloud Dual-Run Reconciliation (Sub-project #2d) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Checkbox steps.
**Goal:** Compute, per shadow subscription + period, Odoo's would-be charge vs NexaCloud's actual charge and record the delta in `fusion.billing.reconciliation`, so the dual-run can prove parity before any flip.
**Architecture:** A pure `_compute_reconciliation(...)` (testable) + `_reconcile_rows(rows)` (resolves the shadow sub → flat + charge, upserts recon rows) + a read-only `_read_reconciliation_rows()` (psycopg2, integration glue). Triggered from the import wizard + cron. Odoo-only; reads NexaCloud, writes only reconciliation rows.
**Tech Stack:** Odoo 19 Enterprise, `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-reconciliation-design.md`
---
## Task 1: 2a amendment — store the NexaCloud plan id on the shadow subscription
**Files:** `models/sale_order.py`, `wizards/import_wizard.py`, `tests/test_importer.py`
- [ ] **Step 1: failing test** (append to `TestImporterSubscriptions` in `tests/test_importer.py`):
```python
def test_subscription_records_nexacloud_plan_id(self):
self.Wizard._import_rows(_fixture())
sub1 = self.env['sale.order'].search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
self.assertEqual(sub1.x_fc_nexacloud_plan_id, 'p-1')
```
- [ ] **Step 2: run** `bash scripts/fcb_test_on_trial.sh` → FAIL (field missing).
- [ ] **Step 3: add the field** to `models/sale_order.py` (next to the other `x_fc_*`):
```python
x_fc_nexacloud_plan_id = fields.Char(index=True, copy=False)
```
- [ ] **Step 4: set it in the importer.** In `wizards/import_wizard.py` `_import_subscription`, add the plan id to both the `shadow_vals` dict (so re-runs keep it current) :
```python
shadow_vals = {
"x_fc_nexacloud_deployment_id": str(srow.get("deployment_id") or ""),
"x_fc_nexacloud_plan_id": str(srow.get("plan_id") or ""),
"x_fc_billing_service_id": service.id, "x_fc_shadow": True,
}
```
- [ ] **Step 5: run** → PASS.
- [ ] **Step 6: commit** `feat(billing): record NexaCloud plan id on shadow subscription (for reconciliation)`
---
## Task 2: pure reconciliation math
**Files:** `models/reconciliation.py`, `tests/test_reconciliation.py` (new), `tests/__init__.py`
- [ ] **Step 1:** append `from . import test_reconciliation` to `tests/__init__.py`.
- [ ] **Step 2: failing test** `tests/test_reconciliation.py`:
```python
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestReconciliationMath(TransactionCase):
def setUp(self):
super().setUp()
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
self.metric = self.env['fusion.billing.metric'].sudo().create(
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
self.charge = self.env['fusion.billing.charge'].sudo().create({
'name': 'CPU', 'plan_code': 'p-1', 'metric_id': self.metric.id,
'included_quota': 18000.0, 'price_per_unit': 0.0075,
'unit_batch': 3600.0, 'charge_model': 'standard'})
def test_match_within_tolerance(self):
# flat 20 + 0 overage (under quota) vs external 20.00 -> match
odoo_amt, delta, status = self.Recon._compute_reconciliation(
20.0, self.charge, 10000.0, 20.0, 0.01)
self.assertAlmostEqual(odoo_amt, 20.0)
self.assertEqual(status, 'match')
def test_overage_match(self):
# flat 20 + 2 core-hours overage (7200s -> $0.015) = 20.015 vs external 20.015
odoo_amt, delta, status = self.Recon._compute_reconciliation(
20.0, self.charge, 18000.0 + 7200.0, 20.015, 0.01)
self.assertAlmostEqual(odoo_amt, 20.015, places=4)
self.assertEqual(status, 'match')
def test_delta_flags_mismatch(self):
odoo_amt, delta, status = self.Recon._compute_reconciliation(
20.0, self.charge, 18000.0, 25.0, 0.01) # external 25 vs odoo 20
self.assertAlmostEqual(delta, -5.0, places=2)
self.assertEqual(status, 'delta')
```
- [ ] **Step 3: run** → FAIL (`_compute_reconciliation` missing).
- [ ] **Step 4: implement** in `models/reconciliation.py` (add `from odoo import api, fields, models`):
```python
@api.model
def _compute_reconciliation(self, flat_amount, charge, cpu_seconds, external_amount,
tolerance=0.01):
"""Return (odoo_amount, delta, status). odoo = flat + overage(cpu_seconds);
delta = odoo - external; status 'match' if |delta| <= tolerance else 'delta'."""
_units, overage = charge._compute_billable(cpu_seconds) if charge else (0.0, 0.0)
odoo_amount = round((flat_amount or 0.0) + (overage or 0.0), 2)
delta = round(odoo_amount - (external_amount or 0.0), 2)
status = 'match' if abs(delta) <= (tolerance or 0.0) else 'delta'
return odoo_amount, delta, status
```
- [ ] **Step 5: run** → PASS.
- [ ] **Step 6: commit** `feat(billing): reconciliation math (odoo-computed vs external)`
---
## Task 3: `_reconcile_rows` — resolve shadow sub and upsert recon rows
**Files:** `models/reconciliation.py`, `tests/test_reconciliation.py`
- [ ] **Step 1: failing test** (append):
```python
@tagged('post_install', '-at_install')
class TestReconcileRows(TransactionCase):
def setUp(self):
super().setUp()
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
from odoo.addons.fusion_centralize_billing.tests.test_importer import _fixture
self.Wizard._import_rows(_fixture()) # creates shadow subs + p-1 charge
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
def test_creates_one_row_per_subscription_with_status(self):
# s-1 monthly flat 20, no overage; external 20.00 -> match.
# s-2 yearly flat 200; external 250 -> delta -50.
summary = self.Recon._reconcile_rows([
{'subscription_external_id': 's-1', 'period': '2026-05',
'cpu_seconds': 0.0, 'external_amount': 20.0},
{'subscription_external_id': 's-2', 'period': '2026-05',
'cpu_seconds': 0.0, 'external_amount': 250.0},
])
rows = self.Recon.search([('period', '=', '2026-05')])
self.assertEqual(len(rows), 2)
s1 = rows.filtered(lambda r: r.odoo_amount == 20.0)
self.assertEqual(s1.status, 'match')
s2 = rows.filtered(lambda r: r.odoo_amount == 200.0)
self.assertEqual(s2.status, 'delta')
self.assertAlmostEqual(s2.delta, -50.0, places=2)
self.assertEqual(summary['match'], 1)
self.assertEqual(summary['delta'], 1)
def test_rerun_upserts(self):
row = [{'subscription_external_id': 's-1', 'period': '2026-05',
'cpu_seconds': 0.0, 'external_amount': 20.0}]
self.Recon._reconcile_rows(row)
self.Recon._reconcile_rows(row)
self.assertEqual(self.Recon.search_count(
[('period', '=', '2026-05'),
('partner_id', '=', self.env['sale.order'].search(
[('x_fc_nexacloud_subscription_id', '=', 's-1')]).partner_id.id)]), 1)
def test_unknown_subscription_is_skipped(self):
summary = self.Recon._reconcile_rows([
{'subscription_external_id': 'nope', 'period': '2026-05',
'cpu_seconds': 0.0, 'external_amount': 1.0}])
self.assertTrue(any(s['id'] == 'nope' for s in summary['skipped']))
```
- [ ] **Step 2: run** → FAIL.
- [ ] **Step 3: implement** in `models/reconciliation.py`:
```python
@api.model
def _reconcile_rows(self, rows, tolerance=0.01):
SaleOrder = self.env['sale.order']
Charge = self.env['fusion.billing.charge']
Service = self.env['fusion.billing.service']
service = Service.search([('code', '=', 'nexacloud')], limit=1)
summary = {'match': 0, 'delta': 0, 'skipped': [], 'failed': []}
for r in rows:
sub_ext = str(r.get('subscription_external_id') or '')
period = str(r.get('period') or '')
try:
sub = SaleOrder.search(
[('x_fc_nexacloud_subscription_id', '=', sub_ext)], limit=1)
if not sub:
summary['skipped'].append({'id': sub_ext, 'reason': 'unknown subscription'})
continue
charge = Charge.search(
[('plan_code', '=', sub.x_fc_nexacloud_plan_id)], limit=1)
plan_line = sub.order_line.filtered(
lambda l: l.product_id.default_code
and l.product_id.default_code.startswith('NC-PLAN-'))
flat = plan_line[:1].price_unit
odoo_amount, delta, status = self._compute_reconciliation(
flat, charge, float(r.get('cpu_seconds') or 0.0),
float(r.get('external_amount') or 0.0), tolerance)
vals = {
'service_id': service.id if service else False,
'partner_id': sub.partner_id.id, 'period': period,
'odoo_amount': odoo_amount,
'external_amount': float(r.get('external_amount') or 0.0),
'delta': delta, 'status': status,
}
existing = self.search([
('service_id', '=', vals['service_id']),
('partner_id', '=', sub.partner_id.id), ('period', '=', period)], limit=1)
if existing:
existing.write(vals)
else:
self.create(vals)
summary['match' if status == 'match' else 'delta'] += 1
except Exception as e: # noqa: BLE001 - per-row isolation
summary['failed'].append({'id': sub_ext, 'error': '%s: %s' % (type(e).__name__, e)})
return summary
```
- [ ] **Step 4: run** → PASS.
- [ ] **Step 5: commit** `feat(billing): reconcile shadow subscriptions -> fusion.billing.reconciliation`
---
## Task 4: read NexaCloud actuals + wizard trigger
**Files:** `wizards/import_wizard.py`, `views/import_wizard_views.xml`
- [ ] **Step 1: add the reader** in `wizards/import_wizard.py` (reuses the DSN + the same connect/guard pattern as `_read_nexacloud_rows`). Aggregate usage cpu_hours per (subscription, period) and the invoice subtotal per (subscription, period); return rows shaped for `_reconcile_rows`:
```python
def _read_reconciliation_rows(self):
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)
# period label = YYYY-MM of the usage period_start; cpu_seconds = cpu_hours*3600
cur.execute("""
SELECT u.subscription_id::text AS subscription_external_id,
to_char(u.period_start, 'YYYY-MM') AS period,
COALESCE(SUM(u.cpu_hours), 0) * 3600.0 AS cpu_seconds
FROM usage_records u
GROUP BY u.subscription_id, to_char(u.period_start, 'YYYY-MM')""")
usage = {(r['subscription_external_id'], r['period']): r for r in cur.fetchall()}
cur.execute("""
SELECT i.subscription_id::text AS subscription_external_id,
to_char(ii.period_start, 'YYYY-MM') AS period,
COALESCE(SUM(i.subtotal), 0) AS external_amount
FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.id
GROUP BY i.subscription_id, to_char(ii.period_start, 'YYYY-MM')""")
rows = []
for r in cur.fetchall():
key = (r['subscription_external_id'], r['period'])
rows.append({
'subscription_external_id': r['subscription_external_id'],
'period': r['period'],
'cpu_seconds': float((usage.get(key) or {}).get('cpu_seconds') or 0.0),
'external_amount': float(r['external_amount'] or 0.0)})
return rows
except psycopg2.Error as e:
raise UserError("Failed reading NexaCloud actuals — schema may have changed:\n%s" % e)
finally:
conn.close()
def action_run_reconciliation(self):
self.ensure_one()
rows = self._read_reconciliation_rows()
summary = self.env['fusion.billing.reconciliation']._reconcile_rows(rows)
self.result_summary = json.dumps(summary, indent=2, default=str)
self.failed_count = len(summary.get('failed') or [])
if summary.get('delta') or summary.get('failed'):
_logger.error("NexaCloud reconciliation: %s delta / %s failed row(s): %s",
summary.get('delta'), len(summary.get('failed') or []), summary)
return {"type": "ir.actions.act_window", "res_model": self._name,
"res_id": self.id, "view_mode": "form", "target": "new"}
```
- [ ] **Step 2: add the button** to `views/import_wizard_views.xml` footer:
```xml
<button name="action_run_reconciliation" type="object"
string="Run Reconciliation" class="btn-secondary"/>
```
- [ ] **Step 3:** `bash scripts/fcb_test_on_trial.sh``FCB_EXIT=0` (module upgrades; reader is integration-only, not unit-tested).
- [ ] **Step 4: commit** `feat(billing): NexaCloud reconciliation reader + wizard trigger`
---
## Task 5: 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 = 2d complete
The dual-run can be run each cycle (button/cron): it reads NexaCloud usage + invoice subtotals, computes Odoo's would-be charge, and records per-subscription `match`/`delta` rows. Flip happens (manually) once a cycle is all-match.

View File

@@ -0,0 +1,89 @@
# Sub-project #2d — NexaCloud Dual-Run Reconciliation (Design)
- **Date:** 2026-05-27
- **Status:** Design (proceeding straight to build — approach determined by parent spec §10)
- **Module:** `fusion_centralize_billing` (Odoo 19 Enterprise; tested on odoo-trial)
- **Parent:** Sub-project #2. Depends on **2a** (the importer creates the shadow subscriptions + the `cpu_seconds` charge catalog this reconciles against).
- **Model already exists:** `fusion.billing.reconciliation` (`service_id`, `partner_id`, `period`, `odoo_amount`, `external_amount`, `delta`, `status` ∈ match/delta/resolved, `note`).
## 1. Goal
Prove, for ≥ 1 billing cycle, that Odoo's billing engine computes the **same charge** as
NexaCloud already does — per subscription, per period — before any real billing is flipped.
Read-only against NexaCloud; writes only `fusion.billing.reconciliation` rows in Odoo.
## 2. What gets compared
For each imported shadow subscription and period:
- **`external_amount`** = NexaCloud's **actual** pre-tax charge for that subscription+period
(the NexaCloud invoice **subtotal**, i.e. flat plan + its own metered overage, before HST).
- **`odoo_amount`** = what **Odoo would charge** for the same period:
`flat + overage`, where
- `flat` = the shadow subscription's plan-product line `price_unit` (the imported flat price), and
- `overage` = `charge._compute_billable(cpu_seconds)[1]` for the period's CPU usage, with
`cpu_seconds = Σ usage_records.cpu_hours × 3600` (the 2a unit convention).
- **`delta`** = `odoo_amount external_amount`.
- **`status`** = `match` if `abs(delta) ≤ tolerance` (default $0.01, configurable), else `delta`.
Comparing **pre-tax subtotals** keeps it apples-to-apples — HST is native Odoo and not what
we're validating; the metered math + catalog mapping is.
## 3. Architecture (mirrors 2a: pure compute split from the read)
- **`_compute_reconciliation(flat_amount, charge, cpu_seconds, external_amount, tolerance)`**
`(odoo_amount, delta, status)`. Pure, deterministic, unit-tested with fixtures. This is
the reconciliation core.
- **`_reconcile_rows(rows, tolerance=0.01)`** — pure Odoo: for each input row
`{subscription_external_id, period, cpu_seconds, external_amount}`, resolve the shadow
`sale.order` (by `x_fc_nexacloud_subscription_id`), its `flat` (plan-line `price_unit`) and
its `charge` (by `x_fc_nexacloud_plan_id``charge.plan_code`), call
`_compute_reconciliation`, and **upsert** one `fusion.billing.reconciliation` row keyed by
`(service_id, partner_id, period)`. Returns a summary `{match, delta, skipped, failed}`.
- **`_read_reconciliation_rows(period=None)`** — read-only `psycopg2` (reuses the 2a DSN):
per subscription+period, `Σ usage_records.cpu_hours` and the NexaCloud invoice **subtotal**.
Integration glue (validated manually, like 2a's reader); not unit-tested against a foreign DB.
- **Trigger:** a button on the existing import wizard (**“Run Reconciliation”**) and a model
method suitable for an `ir.cron`. A non-zero `delta`/`failed` count is surfaced loudly
(banner + ERROR log), same as the importer.
## 4. 2a amendment (small, required)
Add **`x_fc_nexacloud_plan_id`** (`Char`) to `sale.order` and set it in the importer's
`_import_subscription` (from `subscription.plan_id`). Reconciliation needs sub → plan → charge,
and parsing it out of the product `default_code` would be fragile.
## 5. Idempotency / re-runnability
Reconciliation rows upsert on `(service_id, partner_id, period)`, so re-running a period
updates its row rather than duplicating — the dual-run is run every cycle.
## 6. Shadow-safety
Reconciliation is pure measurement: it reads NexaCloud and writes only
`fusion.billing.reconciliation`. It never touches subscriptions, invoices, payments, or the
charge catalog, so the 2a shadow guarantees are untouched.
## 7. Testing
`TransactionCase` on odoo-trial with fixtures:
- `_compute_reconciliation`: under-quota match; overage match; a real delta flips status to
`delta`; tolerance boundary.
- `_reconcile_rows`: creates one recon row per subscription; `match` vs `delta` set correctly;
re-run upserts (no duplicate); a row for an unknown subscription/charge lands in
`skipped`/`failed`, not a crash.
- amendment: importer sets `x_fc_nexacloud_plan_id`.
## 8. Out of scope
- The **flip** (set `charge.plan_id`, attach tokens, confirm subs) — happens once deltas are
within tolerance for ≥ 1 cycle; not automated here.
- Reading NexaCloud RAM/disk/bandwidth (CPU is the only metered-overage metric in v1, per 2a).
- A reconciliation dashboard/report view beyond the list of `fusion.billing.reconciliation`.
## 9. Success criteria
- For fixture data where Odoo's math equals NexaCloud's, every row is `match`; where it
diverges beyond tolerance, the row is `delta` with the correct signed `delta`.
- Re-running a period upserts (no duplicate rows).
- Full suite green on odoo-trial (`FCB_EXIT=0`).

View File

@@ -1,7 +1,12 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import fields, models
import logging
from odoo import api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionBillingReconciliation(models.Model):
@@ -21,6 +26,11 @@ class FusionBillingReconciliation(models.Model):
)
partner_id = fields.Many2one("res.partner", required=True, ondelete="cascade", index=True)
period = fields.Char(required=True, help="Billing period label, e.g. 2026-05.")
external_subscription_id = fields.Char(
index=True,
help="Source-app subscription id this row reconciles (NexaCloud sub UUID). Part of "
"the upsert key so a customer with multiple deployments gets one row PER "
"subscription per period, not a single colliding row.")
odoo_amount = fields.Monetary()
external_amount = fields.Monetary(string="App-actual Amount")
delta = fields.Monetary(help="odoo_amount - external_amount.")
@@ -37,3 +47,82 @@ class FusionBillingReconciliation(models.Model):
default="delta", required=True, index=True,
)
note = fields.Text()
_service_sub_period_uniq = models.Constraint(
"UNIQUE(service_id, external_subscription_id, period)",
"One reconciliation row per service, subscription, and period.",
)
@api.model
def _compute_reconciliation(self, flat_amount, charge, cpu_seconds, external_amount,
tolerance=0.01):
"""Return (odoo_amount, delta, status).
odoo_amount = flat + CPU overage(cpu_seconds); delta = odoo - external;
status 'match' if |delta| <= tolerance else 'delta'. Amounts are compared at cent
precision (the dual-run cares about cent-level invoice parity)."""
overage = 0.0
if charge:
_units, overage = charge._compute_billable(cpu_seconds)
odoo_amount = round((flat_amount or 0.0) + (overage or 0.0), 2)
delta = round(odoo_amount - (external_amount or 0.0), 2)
status = 'match' if abs(delta) <= (tolerance or 0.0) else 'delta'
return odoo_amount, delta, status
@api.model
def _reconcile_rows(self, rows, tolerance=0.01):
"""For each {subscription_external_id, period, cpu_seconds, external_amount},
resolve the shadow sale.order, compute Odoo-vs-external, and UPSERT one
reconciliation row keyed by (service_id, partner_id, period). Per-row isolated."""
SaleOrder = self.env['sale.order']
Charge = self.env['fusion.billing.charge']
service = self.env['fusion.billing.service'].search(
[('code', '=', 'nexacloud')], limit=1)
if not service:
raise UserError(
"NexaCloud billing service not found — run the importer first so the "
"service, catalog, and shadow subscriptions exist.")
summary = {'match': 0, 'delta': 0, 'skipped': [], 'failed': []}
for r in rows:
sub_ext = str(r.get('subscription_external_id') or '')
period = str(r.get('period') or '')
try:
sub = SaleOrder.search(
[('x_fc_nexacloud_subscription_id', '=', sub_ext)], limit=1)
if not sub:
summary['skipped'].append(
{'id': sub_ext, 'reason': 'unknown subscription'})
continue
charge = Charge.search(
[('plan_code', '=', sub.x_fc_nexacloud_plan_id)], limit=1)
plan_line = sub.order_line.filtered(
lambda l: l.product_id.default_code
and l.product_id.default_code.startswith('NC-PLAN-'))
flat = plan_line[:1].price_unit
external_amount = float(r.get('external_amount') or 0.0)
odoo_amount, delta, status = self._compute_reconciliation(
flat, charge, float(r.get('cpu_seconds') or 0.0),
external_amount, tolerance)
vals = {
'service_id': service.id,
'partner_id': sub.partner_id.id, 'period': period,
'external_subscription_id': sub_ext,
'odoo_amount': odoo_amount, 'external_amount': external_amount,
'delta': delta, 'status': status,
}
# Upsert per (service, subscription, period) — NOT per partner — so a
# customer with two deployments gets a row for each, no overwrite.
existing = self.search([
('service_id', '=', service.id),
('external_subscription_id', '=', sub_ext),
('period', '=', period)], limit=1)
if existing:
existing.write(vals)
else:
self.create(vals)
summary['match' if status == 'match' else 'delta'] += 1
except Exception as e: # noqa: BLE001 - per-row isolation
_logger.exception("Reconciliation row %s failed", sub_ext)
summary['failed'].append(
{'id': sub_ext, 'error': '%s: %s' % (type(e).__name__, e)})
return summary

View File

@@ -11,6 +11,9 @@ class SaleOrder(models.Model):
index=True, copy=False,
help="Source NexaCloud subscription id — the importer's idempotency key.")
x_fc_nexacloud_deployment_id = fields.Char(index=True, copy=False)
x_fc_nexacloud_plan_id = fields.Char(
index=True, copy=False,
help="Source NexaCloud plan id — links the shadow sub to its charge for 2d reconciliation.")
x_fc_billing_service_id = fields.Many2one(
"fusion.billing.service", index=True, copy=False, ondelete="set null")
x_fc_shadow = fields.Boolean(

View File

@@ -109,6 +109,34 @@ class FusionBillingService(models.Model):
self, ext, name=payload.get('name'), email=payload.get('email'))
return {'status': 'ok', 'partner_id': link.partner_id.id, 'external_id': ext}
def _fc_resolve_subscription(self, external_ref):
"""Resolve the subscription sale.order a usage event targets.
Prefer the source app's OWN id (``x_fc_nexacloud_subscription_id`` scoped to this
service) so apps reference their own ids — this is what lets NexaCloud push usage
against shadow subscriptions the importer created from its UUIDs. Falls back to a
direct Odoo ``sale.order`` id for live-created subs (post-flip). Authorization is
still enforced by the caller (partner must be linked to this service)."""
self.ensure_one()
SaleOrder = self.env['sale.order']
sub = SaleOrder.search([
('x_fc_nexacloud_subscription_id', '=', str(external_ref)),
('x_fc_billing_service_id', '=', self.id),
], limit=1)
if sub:
return sub
try:
candidate = SaleOrder.browse(int(external_ref))
except (TypeError, ValueError):
return SaleOrder
# Don't let the integer fallback reach a DIFFERENT service's tagged subscription.
# (Live, API-created subs carry no service tag and stay resolvable here; the caller
# still enforces partner-is-linked-to-this-service authorization.)
if candidate.exists() and candidate.x_fc_billing_service_id \
and candidate.x_fc_billing_service_id != self:
return SaleOrder
return candidate
def _api_record_usage(self, payload):
"""Ingest a batch of usage events.
@@ -139,15 +167,11 @@ class FusionBillingService(models.Model):
'period_start', 'period_end'):
if ev.get(key) in (None, ''):
return {'status': 'error', 'error': 'missing %s' % key}
try:
sub_id = int(ev['subscription_external_id'])
except (TypeError, ValueError):
return {'status': 'error', 'error': 'invalid subscription_external_id'}
try:
quantity = float(ev['quantity'])
except (TypeError, ValueError):
return {'status': 'error', 'error': 'invalid quantity'}
sub = self.env['sale.order'].browse(sub_id)
sub = self._fc_resolve_subscription(ev['subscription_external_id'])
if not sub.exists() or not sub.is_subscription \
or sub.partner_id not in linked_partners:
return {'status': 'error', 'error': 'unknown subscription'}

View File

@@ -4,3 +4,4 @@ from . import test_usage
from . import test_api
from . import test_webhook
from . import test_importer
from . import test_reconciliation

View File

@@ -118,6 +118,11 @@ class TestImporterSubscriptions(TransactionCase):
self.assertAlmostEqual(line2.price_unit, 200.0) # price_yearly
self.assertEqual(sub2.plan_id.billing_period_unit, 'year')
def test_subscription_records_nexacloud_plan_id(self):
self.Wizard._import_rows(_fixture())
sub1 = self.env['sale.order'].search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
self.assertEqual(sub1.x_fc_nexacloud_plan_id, 'p-1')
def test_subscription_skipped_when_user_or_plan_unresolved(self):
data = _fixture()
data['subscriptions'].append(
@@ -249,3 +254,26 @@ class TestImporterReadGuard(TransactionCase):
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
with self.assertRaises(UserError):
wiz.action_test_connection()
@tagged('post_install', '-at_install')
class TestUsageApiSourceId(TransactionCase):
"""The /usage API must resolve a subscription by NexaCloud's OWN id, so usage can be
pushed against shadow subs the importer created from UUIDs (the flip-day gap)."""
def setUp(self):
super().setUp()
self.env['fusion.billing.import.wizard'].sudo()._import_rows(_fixture())
self.service = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')])
def test_record_usage_resolves_by_nexacloud_subscription_id(self):
res = self.service._api_record_usage({'events': [{
'subscription_external_id': 's-1', # NexaCloud UUID, not the Odoo id
'metric_code': 'cpu_seconds', 'quantity': 3600.0,
'period_start': '2026-05-01', 'period_end': '2026-06-01',
'idempotency_key': 'nc:s-1:2026-05'}]})
self.assertEqual(res['status'], 'ok')
self.assertEqual(res['accepted'], 1)
sub = self.env['sale.order'].search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)])
self.assertEqual(usage.quantity, 3600.0)

View File

@@ -0,0 +1,111 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo.tests.common import TransactionCase, tagged
from .test_importer import _fixture
@tagged('post_install', '-at_install')
class TestReconciliationMath(TransactionCase):
def setUp(self):
super().setUp()
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
self.metric = self.env['fusion.billing.metric'].sudo().create(
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
self.charge = self.env['fusion.billing.charge'].sudo().create({
'name': 'CPU', 'plan_code': 'p-1', 'metric_id': self.metric.id,
'included_quota': 18000.0, 'price_per_unit': 0.0075,
'unit_batch': 3600.0, 'charge_model': 'standard'})
def test_match_within_tolerance(self):
odoo_amt, delta, status = self.Recon._compute_reconciliation(
20.0, self.charge, 10000.0, 20.0, 0.01) # under quota, no overage
self.assertAlmostEqual(odoo_amt, 20.0)
self.assertEqual(status, 'match')
def test_overage_match(self):
# flat 20 + 2 core-hours overage (7200s -> $0.015) = 20.015; external 20.02 (cent)
odoo_amt, delta, status = self.Recon._compute_reconciliation(
20.0, self.charge, 18000.0 + 7200.0, 20.02, 0.01)
self.assertEqual(status, 'match')
def test_delta_flags_mismatch(self):
odoo_amt, delta, status = self.Recon._compute_reconciliation(
20.0, self.charge, 18000.0, 25.0, 0.01) # external 25 vs odoo 20
self.assertAlmostEqual(delta, -5.0, places=2)
self.assertEqual(status, 'delta')
def test_no_charge_is_flat_only(self):
odoo_amt, delta, status = self.Recon._compute_reconciliation(
20.0, self.env['fusion.billing.charge'], 999999.0, 20.0, 0.01)
self.assertAlmostEqual(odoo_amt, 20.0)
self.assertEqual(status, 'match')
@tagged('post_install', '-at_install')
class TestReconcileRows(TransactionCase):
def setUp(self):
super().setUp()
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
self.Wizard._import_rows(_fixture()) # shadow subs s-1/s-2 + p-1 charge
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
self.SaleOrder = self.env['sale.order']
def _partner_of(self, sub_ext):
return self.SaleOrder.search(
[('x_fc_nexacloud_subscription_id', '=', sub_ext)]).partner_id
def test_creates_one_row_per_subscription_with_status(self):
summary = self.Recon._reconcile_rows([
{'subscription_external_id': 's-1', 'period': '2026-05',
'cpu_seconds': 0.0, 'external_amount': 20.0}, # flat 20 == 20 -> match
{'subscription_external_id': 's-2', 'period': '2026-05',
'cpu_seconds': 0.0, 'external_amount': 250.0}, # flat 200 vs 250 -> delta
])
rows = self.Recon.search([('period', '=', '2026-05')])
self.assertEqual(len(rows), 2)
s1 = rows.filtered(lambda r: r.odoo_amount == 20.0)
self.assertEqual(s1.status, 'match')
s2 = rows.filtered(lambda r: r.odoo_amount == 200.0)
self.assertEqual(s2.status, 'delta')
self.assertAlmostEqual(s2.delta, -50.0, places=2)
self.assertEqual(summary['match'], 1)
self.assertEqual(summary['delta'], 1)
def test_rerun_upserts(self):
row = [{'subscription_external_id': 's-1', 'period': '2026-05',
'cpu_seconds': 0.0, 'external_amount': 20.0}]
self.Recon._reconcile_rows(row)
self.Recon._reconcile_rows(row)
self.assertEqual(self.Recon.search_count([
('period', '=', '2026-05'),
('partner_id', '=', self._partner_of('s-1').id)]), 1)
def test_unknown_subscription_is_skipped(self):
summary = self.Recon._reconcile_rows([
{'subscription_external_id': 'nope', 'period': '2026-05',
'cpu_seconds': 0.0, 'external_amount': 1.0}])
self.assertTrue(any(s['id'] == 'nope' for s in summary['skipped']))
def test_two_subscriptions_same_partner_period_do_not_collide(self):
# A customer with two deployments -> two subscriptions in the same period.
data = _fixture()
data['subscriptions'].append(
{"id": "s-1b", "user_id": "u-1", "deployment_id": "d-1b", "plan_id": "p-1",
"status": "active", "billing_cycle": "monthly",
"current_period_start": "2026-05-01", "current_period_end": "2026-06-01"})
self.env['fusion.billing.import.wizard'].sudo()._import_rows(data)
self.Recon._reconcile_rows([
{'subscription_external_id': 's-1', 'period': '2026-05',
'cpu_seconds': 0.0, 'external_amount': 20.0},
{'subscription_external_id': 's-1b', 'period': '2026-05',
'cpu_seconds': 0.0, 'external_amount': 99.0},
])
partner = self._partner_of('s-1')
rows = self.Recon.search(
[('partner_id', '=', partner.id), ('period', '=', '2026-05')])
self.assertEqual(len(rows), 2, "two subs for one partner must keep two rows")
self.assertEqual(set(rows.mapped('external_subscription_id')), {'s-1', 's-1b'})

View File

@@ -23,6 +23,8 @@
string="Test Connection" class="btn-secondary"/>
<button name="action_run_import" type="object" string="Run Import"
class="btn-primary"/>
<button name="action_run_reconciliation" type="object"
string="Run Reconciliation" class="btn-secondary"/>
<button string="Close" class="btn-secondary" special="cancel"/>
</footer>
</form>

View File

@@ -76,6 +76,26 @@ class FusionBillingImportWizard(models.TransientModel):
"type": "success", "sticky": False},
}
def action_run_reconciliation(self):
"""Read NexaCloud usage + invoice actuals and record per-subscription/period
Odoo-vs-NexaCloud deltas in fusion.billing.reconciliation. Read-only on
NexaCloud; writes only reconciliation rows (shadow-safe)."""
self.ensure_one()
rows = self._read_reconciliation_rows()
summary = self.env["fusion.billing.reconciliation"]._reconcile_rows(rows)
self.result_summary = json.dumps(summary, indent=2, default=str)
self.failed_count = len(summary.get("failed") or [])
self.skipped_count = len(summary.get("skipped") or [])
if summary.get("delta") or summary.get("failed"):
_logger.error(
"NexaCloud reconciliation: %s delta, %s failed, %s skipped row(s): %s",
summary.get("delta"), len(summary.get("failed") or []),
len(summary.get("skipped") or []), summary)
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_rows(self):
"""Open a READ-ONLY psycopg2 connection to the nexacloud Postgres (DSN in
@@ -122,6 +142,55 @@ class FusionBillingImportWizard(models.TransientModel):
finally:
conn.close()
def _read_reconciliation_rows(self):
"""Read-only: per (subscription, YYYY-MM period), NexaCloud's CPU usage
(cpu_hours*3600 = cpu_seconds) and its actual pre-tax invoice amount. Shaped for
fusion.billing.reconciliation._reconcile_rows. Reuses the 2a DSN + guards.
(Integration glue — validate the SQL against the live schema, like the importer
reader; the reconciliation math itself is unit-tested.)"""
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)
cur.execute(
"SELECT subscription_id::text AS sub, "
"to_char(period_start, 'YYYY-MM') AS period, "
"COALESCE(SUM(cpu_hours), 0) * 3600.0 AS cpu_seconds "
"FROM usage_records "
"GROUP BY subscription_id, to_char(period_start, 'YYYY-MM')")
usage = {(r["sub"], r["period"]): float(r["cpu_seconds"] or 0.0)
for r in cur.fetchall()}
cur.execute(
"SELECT i.subscription_id::text AS sub, "
"to_char(ii.period_start, 'YYYY-MM') AS period, "
"COALESCE(SUM(ii.amount), 0) AS external_amount "
"FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.id "
"GROUP BY i.subscription_id, to_char(ii.period_start, 'YYYY-MM')")
rows = []
for r in cur.fetchall():
key = (r["sub"], r["period"])
rows.append({
"subscription_external_id": r["sub"], "period": r["period"],
"cpu_seconds": usage.get(key, 0.0),
"external_amount": float(r["external_amount"] or 0.0)})
return rows
except psycopg2.Error as e:
raise UserError(
"Failed reading NexaCloud actuals — the source schema may have changed. "
"Underlying error:\n%s" % e)
finally:
conn.close()
# ----- import side (pure Odoo; unit-tested) ------------------------------
@api.model
def _import_rows(self, data, dry_run=False):
@@ -346,6 +415,7 @@ class FusionBillingImportWizard(models.TransientModel):
# order that may since have been confirmed.
shadow_vals = {
"x_fc_nexacloud_deployment_id": str(srow.get("deployment_id") or ""),
"x_fc_nexacloud_plan_id": str(srow.get("plan_id") or ""),
"x_fc_billing_service_id": service.id, "x_fc_shadow": True,
}
existing = SaleOrder.search(

View File

@@ -3,7 +3,7 @@
# License OPL-1
{
'name': 'Fusion Helpdesk Central — Client API Keys',
'version': '19.0.2.3.3',
'version': '19.0.2.3.4',
'category': 'Productivity',
'summary': 'Admin UI on the central Odoo for issuing per-client API '
'keys used by fusion_helpdesk client deployments.',

View File

@@ -331,12 +331,16 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
# ------------------------------------------------------------------
def action_generate_summary(self):
"""Single mode: fire OpenAI with the current findings, drop the
result into ai_summary. Returns True (no navigation) so the
wizard stays open and the form view re-renders with the new
summary populated. DO NOT return an `act_window` with
`res_id=self.id` here — that puts the WIZARD's id into the new
action's `active_id`, which then collides with default_get's
ticket lookup and raises "Ticket <wizard_id> no longer exists".
result into ai_summary. Returns an act_window dict that re-opens
the SAME wizard record — the only Odoo-19 idiom that reliably
keeps a target='new' wizard dialog open after a button click.
Returning True/None auto-closes the modal (Odoo's "wizard done"
convention).
The previous self-id collision bug (where active_id of the
re-opened action was the wizard's own id, which default_get
mis-read as a ticket id) is now prevented by default_get's
active_model=='helpdesk.ticket' guard.
"""
self.ensure_one()
if self.mode != 'single' or not self.ticket_id:
@@ -349,15 +353,12 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
)
self.ai_summary = summary
self.ai_unavailable = not bool(summary)
return True
return self._reopen_action()
def action_generate_all_summaries(self):
"""Bulk mode: fire OpenAI per-ticket in parallel using each line's
own findings. Lines that already have a non-empty ai_summary get
regenerated too (the user clicked the button — they meant it).
Returns True for the same reason as action_generate_summary:
keep the dialog open; don't re-navigate.
own findings. Returns an act_window dict to keep the dialog open
(see action_generate_summary for why we can't just return True).
"""
self.ensure_one()
if self.mode != 'bulk' or not self.line_ids:
@@ -373,7 +374,28 @@ class FusionHelpdeskEngagementWizard(models.TransientModel):
line.ai_summary = results.get(line.ticket_id.id, '')
any_ok = any(results.values())
self.ai_unavailable = not any_ok
return True
return self._reopen_action()
def _reopen_action(self):
"""Return the act_window dict that re-opens the same wizard record.
Passes an EXPLICITLY EMPTY context override so the parent ticket
form's active_id / active_model don't leak in and confuse any
future default_get call. The wizard record is loaded directly by
res_id; default_get won't be called for a load (Odoo 19 only
invokes it when creating new records), but the empty context is
belt-and-suspenders against future regressions.
"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Request Owner Approval'),
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
'context': {},
}
# ------------------------------------------------------------------
# Send: write engagement state + queue mail