From 2b47bd8b1082363bf7b5e68e5ab0fae902ea0fc0 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 02:14:19 -0400 Subject: [PATCH 01/37] feat(billing): design + scaffold fusion_centralize_billing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralize billing for all NexaSystems services (NexaCloud, NexaDesk, NexaMaps, custom apps, memberships) on the Odoo 19 Enterprise instance, replacing Lago. The module adds only the metering + integration layer; native sale_subscription / account_accountant / payment_stripe do all the financial work (invoicing, HST, dunning, portal, credit notes, Stripe). Includes: - Design spec (docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md): 6 locked decisions, architecture, data model, usage engine, Lago-shaped API, webhook control loop, NexaCloud pilot, phased dual-run migration. - Module scaffold: 7 fusion.billing.* models (service, account.link, metric, charge, usage, webhook, reconciliation), bearer-auth API controller shell, security ACLs, README. Compiles on Odoo 19.0; engine/API bodies are stubs pending the implementation plan. - CLAUDE.md rule #15: no sale.subscription model in Odoo 19 — a subscription is a sale.order(is_subscription) + sale.subscription.plan (verified live). Task 0 verified: a single Stripe account is shared across NexaCloud and all Lago providers, so no Stripe account/card migration is required. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 + ...6-05-27-nexa-billing-centralized-design.md | 271 ++++++++++++++++++ fusion_centralize_billing/README.md | 70 +++++ fusion_centralize_billing/__init__.py | 2 + fusion_centralize_billing/__manifest__.py | 54 ++++ .../controllers/__init__.py | 1 + fusion_centralize_billing/controllers/api.py | 74 +++++ fusion_centralize_billing/models/__init__.py | 7 + .../models/account_link.py | 33 +++ fusion_centralize_billing/models/charge.py | 53 ++++ fusion_centralize_billing/models/metric.py | 32 +++ .../models/reconciliation.py | 38 +++ fusion_centralize_billing/models/service.py | 56 ++++ fusion_centralize_billing/models/usage.py | 39 +++ fusion_centralize_billing/models/webhook.py | 42 +++ .../security/ir.model.access.csv | 11 + 16 files changed, 785 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md create mode 100644 fusion_centralize_billing/README.md create mode 100644 fusion_centralize_billing/__init__.py create mode 100644 fusion_centralize_billing/__manifest__.py create mode 100644 fusion_centralize_billing/controllers/__init__.py create mode 100644 fusion_centralize_billing/controllers/api.py create mode 100644 fusion_centralize_billing/models/__init__.py create mode 100644 fusion_centralize_billing/models/account_link.py create mode 100644 fusion_centralize_billing/models/charge.py create mode 100644 fusion_centralize_billing/models/metric.py create mode 100644 fusion_centralize_billing/models/reconciliation.py create mode 100644 fusion_centralize_billing/models/service.py create mode 100644 fusion_centralize_billing/models/usage.py create mode 100644 fusion_centralize_billing/models/webhook.py create mode 100644 fusion_centralize_billing/security/ir.model.access.csv diff --git a/CLAUDE.md b/CLAUDE.md index c5033b04..23784c83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,8 @@ 7. **Search views**: NO `group expand="0"` syntax. 8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file. +15. **There is NO `sale.subscription` model in Odoo 19** (Enterprise `sale_subscription`). A subscription is a **`sale.order`** with `is_subscription=True`, `plan_id` → **`sale.subscription.plan`** (the recurrence), plus `subscription_state` / `next_invoice_date` / `recurring_monthly`. Any Many2one or relation that targets "a subscription" must point at `sale.order` (filter `domain=[('is_subscription','=',True)]`) — **not** `sale.subscription`, which does not exist and fails at install. The surviving `sale.subscription.*` records are only the plan + wizards/reports (`sale.subscription.plan`, `sale.subscription.report`, `sale.subscription.change.customer.wizard`, `sale.subscription.close.reason.wizard`). Verified on live `nexamain` (odoo-nexa, 19.0): `SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'`. + ## Card Styling — Copy Odoo's Kanban Pattern Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values: ```css diff --git a/docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md b/docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md new file mode 100644 index 00000000..787026d1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md @@ -0,0 +1,271 @@ +# fusion_centralize_billing — Centralized Billing Engine on Odoo 19 + +- **Date:** 2026-05-27 +- **Status:** Design approved — pending written-spec review +- **Author:** Design session (Claude + Gurpreet) +- **Module:** `fusion_centralize_billing` (target: `K:\Github\Odoo-Modules\fusion_centralize_billing`) +- **Host:** odoo-nexa (Proxmox VM 315, worker1), Odoo 19 **Enterprise**, live DB `nexamain` + +## 1. Goal + +Make the Odoo Enterprise instance (`odoo-nexa`) the single billing brain for every +NexaSystems service — hosting (NexaCloud), live chat (NexaDesk/Fusion-Chat), the +metered maps API (NexaMaps), plus custom-app retainers, memberships, and one-off +services. It replaces Lago in the role Lago currently plays, and absorbs NexaCloud's +home-grown Stripe billing, so there is one customer ledger, one accounting system, +one place revenue is recognized. + +## 2. Current state (recon, 2026-05-27) + +Billing is fragmented across **three+ independent engines**: + +| System | Bills for | Engine today | Data home | +|---|---|---|---| +| **NexaCloud** (LXC 102, `10.200.0.250`) | VPS/LXC hosting, Coolify apps, CPU-seconds + throttle-removal fees, snapshots, domains | Own Postgres models + **direct Stripe** (`stripe_service.py`, `billing_service.py`, `usage_metering.py`, `invoice_generator.py`) | `nexacloud` DB (LXC 201) | +| **NexaDesk / Fusion-Chat** (VM 314) | Chat plans (monthly/annual), feature + channel add-ons, message/token overage, token wallets | **Lago** v1.44.0 (VM 318) + Stripe (provider code `nexadesk`) | Lago (VM 318, `192.168.1.117`) | +| **NexaMaps** (`fusionapps.maps_*`) | Metered geocoding/routing API: monthly quota + overage per 1k | Own tables; **~189k usage events / month** for 2 clients | Supabase `fusionapps` | +| Services / memberships | Custom apps, consulting, retainers | ad-hoc / manual | — | + +**Decisive fact:** `odoo-nexa` is **Odoo 19 Enterprise** and already runs the full +Lago-equivalent stack: `sale_subscription` (+ `_stock`, `_timesheet`, +`_external_tax`), `account_accountant`, `payment_stripe`, `website_sale` + +`website_sale_subscription`, `crm/project/industry_fsm_sale_subscription`, plus +custom `nexa_coa_setup`, `fusion_whitelabels`, `fusion_helpdesk_central`, +`fusion_pdf_preview`. So Odoo already does subscriptions, recurring invoicing, full +accounting/GL, Stripe, HST taxes, customer portal, credit notes, and self-serve +checkout. + +**The only capability Lago has that Odoo lacks natively is usage-based metered +billing** (billable metrics → aggregation → quota/overage charges). That, plus the +integration surface, is all we build. + +Prior decision on record (Supabase `fusionapps.decisions`): Lago was deployed as the +centralizer for NexaDesk + NexaCloud. This design **supersedes** that — the billing +brain moves into the Odoo Enterprise already owned and operated. + +## 3. Decisions locked in this session + +1. **Odoo fully replaces Lago.** Build a metered-billing engine inside `fusion_centralize_billing`; decommission Lago VM 318 at the end. +2. **One unified customer, separate invoice per service.** One `res.partner` per real client; each service bills on its own subscription/cycle. No cross-product invoice merging. +3. **Apps drive; Odoo is the billing system of record.** Each app keeps its own signup, provisioning, and entitlement enforcement, and calls Odoo's billing API (the same way it calls Lago today). Odoo invoices, charges Stripe, and emits webhooks back. +4. **Odoo owns the billing catalog; apps own entitlements.** Odoo is SoR for products, prices, recurrence, metric rate/quota/overage, taxes — keyed by a stable `plan_code`. Apps enforce feature limits (max_chatbots, CPU quota, API rate-limit) against the same code. +5. **Pilot = NexaCloud, phased dual-run cutover** (one product at a time, parallel run + reconciliation before flip). +6. **Aggregate-push usage ingestion.** Apps push periodic pre-aggregated counters; Odoo stores rollups and feeds native `sale.subscription` metered lines. No raw-event firehose into Odoo. + +## 4. Architecture + +``` + NexaCloud NexaDesk NexaMaps (apps keep signup + provisioning + entitlements) + │ │ │ + │ customers / subscriptions / usage counters (inbound REST, API-key bearer auth) + ▼ ▼ ▼ +┌──────────────────────────────────────────────┐ +│ fusion_centralize_billing (custom Odoo 19 module) │ +│ • Service registry (one row per app) │ +│ • Identity links (ext acct → res.partner) │ +│ • Metric + Charge catalog (quota/overage) │ +│ • Usage engine (ingest → aggregate → bill) │ +│ • Outbound webhook queue (HMAC + retry) │ +└───────────────┬────────────────────────────────┘ + │ writes billable qty onto + ▼ + sale.order(is_subscription) → account.move → payment_stripe (NATIVE Odoo Enterprise) + │ invoicing, HST tax, proration, + │ invoice paid / failed / sub ended dunning, portal, credit notes + ▼ + outbound webhooks ──► apps suspend / restore / deprovision +``` + +Principle: **build only the metering + integration layer; inherit all financial +behaviour from native Odoo Enterprise.** + +## 5. Data model + +### 5.1 New models (`fusion.billing.*`) + +| Model | Key fields | Purpose | +|---|---|---| +| `fusion.billing.service` | `name`, `code` (nexacloud/nexadesk/nexamaps), `api_key_hash`, `webhook_url`, `webhook_secret`, `active` | One row per source app — the auth + routing boundary. | +| `fusion.billing.account.link` | `service_id`, `external_id`, `partner_id`, `external_email`; unique `(service_id, external_id)` | Identity resolution: folds each app's account into one `res.partner`. | +| `fusion.billing.metric` | `code`, `name`, `aggregation` (sum/max/last/unique_count), `unit_label`, `rounding` | Billable metric definition. | +| `fusion.billing.charge` | `plan_ref`/`product_id`, `metric_id`, `included_quota`, `price_per_unit`, `unit_batch` (e.g. per 1000), `charge_model` (standard/graduated/package/volume) | Maps a plan + metric → quota & overage pricing. Where "5M quota / $0.10 per 1k" lives. | +| `fusion.billing.usage` | `subscription_id`, `metric_id`, `period_start`, `period_end`, `quantity`, `source`, `idempotency_key`; index `(subscription, metric, period)` | **Aggregated** usage rows (rollups, not raw events). | +| `fusion.billing.webhook` | `service_id`, `event_type`, `payload` (JSON), `state` (pending/sent/failed/dead), `attempts`, `next_retry_at`, `signature` | Outbound event queue, processed by cron with backoff + HMAC. | +| `fusion.billing.reconciliation` | `service_id`, `partner_id`, `period`, `odoo_amount`, `external_amount`, `delta`, `status` | Dual-run shadow-mode comparison (Odoo-computed vs app-actual). | + +### 5.2 Native models reused as-is + +`res.partner` (customer), **`sale.order` with `is_subscription=True`** (the subscription), +`sale.subscription.plan` (recurrence/plan), `sale.order.line` (metered lines), +`account.move` (invoice + credit note), `payment_stripe`/`payment.transaction` (Stripe), +`account.tax` (HST per province), customer portal. Catalog = `product.template` + +`sale.subscription.plan`, tagged with the shared `plan_code`. + +New fields on native models use the `x_fc_*` prefix (e.g. `res.partner.x_fc_billing_external_ids`). + +> **Odoo 19 modeling note (verified on live `nexamain`, 2026-05-27):** there is **no +> `sale.subscription` model**. A subscription IS a `sale.order` with `is_subscription=True`, +> `plan_id` → `sale.subscription.plan`, plus `subscription_state` / `next_invoice_date` / +> `recurring_monthly`. Every "subscription" reference in this spec means that. The usage +> engine links `fusion.billing.usage.subscription_id` → `sale.order`. + +### 5.3 Relationship to `fusion_api` (reuse, don't duplicate) + +The existing **`fusion_api`** module (`fusion.api.key` / `.consumer` / `.service` / +`.usage` / `.usage.daily`) centralizes **outbound** provider keys (OpenAI, Anthropic, +Google Maps, Twilio) with cost/usage tracking + rate limiting — i.e. what **Nexa pays +providers** (COGS). It is **complementary**, not a substitute: +`fusion_centralize_billing` tracks what **customers owe Nexa**. Two concrete ties: +(a) feed `fusion.api.usage.daily` cost into margin reporting against billed revenue; +(b) mirror its daily-rollup aggregation pattern for `fusion.billing.usage`. The +customer-facing metered billing and the inbound API remain ours to build. + +## 6. Usage engine (aggregate-push) + +1. Apps `POST /usage` with periodic counters and an `idempotency_key` + (e.g. `service:metric:subscription:window`). NexaCloud pushes CPU-seconds per + deployment hourly; NexaMaps pushes api_calls per client daily; NexaDesk pushes + messages/tokens. Upsert into `fusion.billing.usage` keyed by `idempotency_key` so + retries never double-bill. +2. A **pre-invoice cron** (runs ahead of each subscription's invoice date) sums the + period's `fusion.billing.usage` per metric, applies the matching + `fusion.billing.charge` (quota → free, overage → priced by `charge_model`), and + writes the billable quantity/amount onto the subscription's draft invoice line + (usage product). +3. Native subscription invoicing issues the invoice, applies HST, and charges Stripe. + Quota resets per period. + +At ~189k Maps events/month pushed as daily counters, Odoo stores ≈30 rows per client +per metric per month — trivial volume. + +## 7. Inbound API (Lago-shaped, drop-in) + +Base path `/api/billing/v1/*`. Odoo 19 routing: `type="http"`, `auth="none"`, +`csrf=False`, manual **Bearer** API-key check against `fusion.billing.service` +(hashed), JSON request/response via `request.make_json_response`, per-service rate +limiting. (`type="jsonrpc"` is for Odoo session RPC — not used here, because external +apps authenticate with bearer tokens, not Odoo sessions.) + +Endpoints intentionally mirror `Fusion-Chat/src/lib/billing/lago-client.ts` so the +NexaDesk swap is ≈ one file, and NexaCloud's integration is a thin client: + +| Method · Path | Maps to | +|---|---| +| `POST /customers` | upsert `res.partner` + `account.link` (identity resolution) | +| `POST /subscriptions` · `PUT /subscriptions/:id` · `DELETE /subscriptions/:id` | create / change-upgrade / cancel subscription `sale.order` | +| `POST /usage` | batch aggregated counters (hot path → 202 Accepted) | +| `POST /invoices` | one-off invoice (token packs, throttle-removal fee) | +| `GET /invoices` · `GET /invoices/:id` · `POST /invoices/:id/download` | list / fetch / PDF | +| `POST /invoices/:id/retry_payment` · `POST /invoices/:id/void` | payment retry / void | +| `POST /credit_notes` | refund via `account.move` reversal | +| `GET /plans` · `GET /catalog` | apps fetch pricing (as NexaDesk fetches from Lago) | +| `GET /customers/:id/checkout_url` | Stripe payment-method setup | + +## 8. Outbound webhooks (control loop) + +Odoo → app, HMAC-SHA256 signed, retried with exponential backoff, dead-lettered after +N attempts (reuse the proven pattern in `Fusion-Chat/src/lib/billing/lago-payment-retry-job.ts`): + +| Event | App reaction | +|---|---| +| `invoice.payment_failed` (after dunning) | **suspend** — NexaCloud throttle/network-isolate; NexaDesk suspend tenant; NexaMaps disable API key | +| `invoice.payment_succeeded` / `subscription.reactivated` | **restore** service | +| `subscription.terminated` | **deprovision** | +| `usage.threshold_reached` (80% / 100%, optional) | warn / cap | + +## 9. NexaCloud pilot + +- **Identity & catalog mapping:** `nexacloud.users` → `res.partner` via `account.link`; + `nexacloud.products`/`plans` → `product.template` + subscription plans + (`plan_code` = NexaCloud plan id/slug, prices from `price_monthly`/`price_yearly`); + `nexacloud.deployments` + `subscriptions` → one subscription `sale.order` per deployment + (NexaCloud bills per deployment). +- **Metering:** CPU-seconds → `fusion.billing.metric` `cpu_seconds` (sum) + `charge` + (included = plan quota, overage priced). Throttle-removal fee → one-off invoice + (or add-on product). `nexacloud/.../usage_metering.py` pushes counters to `/usage`. +- **Control loop:** `invoice.payment_failed` → NexaCloud suspends using its existing + `network_isolation` / `throttle_checker` / `resource_manager`; `subscription.terminated` + → NexaCloud deprovisions. + +## 10. Dual-run + migration (phased) + +1. **Import** NexaCloud customers + active subscriptions into Odoo (script reads the + `nexacloud` DB → creates partners / links / subscriptions / charges). +2. **Shadow mode ≥ 1 billing cycle:** Odoo computes invoices while NexaCloud keeps + charging via its own Stripe. `fusion.billing.reconciliation` diffs Odoo-computed vs + NexaCloud-actual per customer/period; investigate every delta. +3. **Flip** when deltas are within tolerance: NexaCloud calls Odoo's API as SoR and + stops its internal Stripe billing. Past invoices stay archived (PDF / opening + balances) — not re-issued. +4. **Repeat** for NexaDesk (retire Lago for chat) → NexaMaps → then decommission + Lago VM 318. + +## 11. Risks & open items + +- **🟢 Stripe account unification — RESOLVED (2026-05-27).** All systems share ONE Stripe + account: **`acct_1ShlA9IkwUB1dVox`** (Nexa Systems Inc, CA, live). Verified live: + NexaCloud's direct `sk_live` key resolves to that account, and Lago has three Stripe + providers (`nexasystems`, `nexadesk`, `nexamaps`) that **all** resolve to the same + account. Therefore **no Stripe account migration is needed** — Odoo's `payment_stripe` + connects to that single account and **reuses existing Stripe customers + saved payment + methods** (map each Stripe `provider_customer_id` → `res.partner`). This removes what + was the biggest migration risk. +- **Idempotency** on usage counters is mandatory (dedupe key) to prevent double billing on retries. +- **Entitlement sync SLA:** on plan change, Odoo webhook informs the app; define how + fast app-side limits must update (and the reconciliation if a webhook is missed). +- **Odoo 19 correctness:** implementation MUST read live reference files from the + container (`docker exec odoo-nexa-app cat …`) before coding subscription/API/account + internals — never from memory (per `K:\Github\CLAUDE.md`). +- **Tax:** HST/GST per Canadian province via `account.tax`; confirm tax codes align + with current Lago `hst_on` usage. +- **Auth hardening:** API keys hashed at rest, per-service scoping, rate limiting, + request audit log; webhook secrets rotated. + +## 12. Phasing — spec sequence + +Each is its own spec → plan → build cycle: + +1. **`fusion_centralize_billing` core** — service registry, identity links, metric/charge catalog, + usage engine, inbound API, outbound webhook engine. *(detailed below — first deliverable)* +2. **NexaCloud adapter + dual-run reconciliation** *(the pilot — coupled to #1)* +3. NexaDesk adapter (swap the Lago client for the Odoo billing client) +4. NexaMaps adapter +5. Lago decommission + memberships/services onboarding + portal polish + +## 13. First-deliverable scope (sub-projects #1 + #2) + +**In scope** +- `fusion_centralize_billing` module skeleton (manifest, security/ACLs + record rules, README) following the `nexa_coa_setup` layout. +- Models in §5.1; new native fields use `x_fc_*`. +- Aggregate-push usage engine (§6) incl. pre-invoice cron + idempotent upsert. +- Inbound API (§7) with bearer auth, and outbound webhook engine (§8). +- NexaCloud mapping + importer + shadow-mode reconciliation (§9, §10). +- Manifest `depends`: `sale_subscription`, `account_accountant`, `payment_stripe`, + `sale_management` (+ `nexa_coa_setup` if COA dependencies apply). + +**Out of scope (YAGNI for now)** +- NexaDesk / NexaMaps adapters (specs #3/#4). +- Raw-event ingestion / per-event audit in Odoo (apps retain raw events). +- Lago decommission (spec #5) — Lago stays running until NexaDesk is migrated. +- Customer-portal redesign — use native portal as-is initially. + +## 14. Success criteria (first deliverable) + +- A NexaCloud deployment can be created as an Odoo subscription `sale.order` via the API, + with one `res.partner` resolving the NexaCloud user. +- CPU-seconds counters pushed to `/usage` aggregate correctly and produce a draft + invoice with quota + overage applied, taxed (HST), and charged through `payment_stripe`. +- A simulated `invoice.payment_failed` delivers a signed webhook NexaCloud can act on. +- Shadow-mode reconciliation report shows Odoo-computed vs NexaCloud-actual within + tolerance for ≥ 1 cycle before any flip. +- No double billing under usage-counter retries (idempotency verified). + +## 15. Open questions for review + +1. ~~Stripe: one account across all products, or separate?~~ **ANSWERED (2026-05-27):** one + account `acct_1ShlA9IkwUB1dVox` for everything (NexaCloud direct + Lago's + `nexasystems`/`nexadesk`/`nexamaps` providers). No account migration; reuse existing + Stripe customers + payment methods. +2. NexaCloud billing granularity — confirm **one subscription per deployment** (vs one per customer with deployment line items). +3. Membership model — Odoo native `membership` module, or model memberships as plain recurring subscriptions? +4. Spec/module commit target — confirm branch strategy in `Odoo-Modules` (currently on `feat/fusion-login-audit`). diff --git a/fusion_centralize_billing/README.md b/fusion_centralize_billing/README.md new file mode 100644 index 00000000..d3a3384b --- /dev/null +++ b/fusion_centralize_billing/README.md @@ -0,0 +1,70 @@ +# Fusion Centralized Billing (`fusion_centralize_billing`) + +Centralized billing engine that makes this Odoo 19 **Enterprise** instance the single +billing brain for every NexaSystems service — **NexaCloud** hosting, **NexaDesk** chat, +**NexaMaps** API, custom apps, and memberships. It replaces Lago and absorbs NexaCloud's +home-grown Stripe billing into one customer ledger and one accounting system. + +> **Design spec:** [`docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`](../docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md) +> +> **Status:** **SCAFFOLD.** Models + security + the API auth shell are in place and the +> module installs. The usage engine, full inbound API, and webhook processor are stubs +> to be implemented from the writing-plans output. + +## Why this module is small + +We build **only** the metering + integration layer. Everything financial — recurring +invoicing, HST tax, proration, dunning, customer portal, credit notes, Stripe — is +**native Odoo Enterprise** (`sale_subscription`, `account_accountant`, `payment_stripe`), +already installed and running. + +## Design decisions (locked) + +1. Odoo fully replaces Lago (we build the metered-billing engine; Lago is decommissioned last). +2. One unified `res.partner` per client; **separate invoice per service**. +3. **Apps drive**, Odoo is the billing system of record — apps call the inbound API (as they call Lago today); Odoo bills and webhooks back. +4. Odoo owns the **billing catalog**; apps own **feature entitlements** (shared `plan_code`). +5. Pilot = **NexaCloud**, phased dual-run cutover. +6. **Aggregate-push** usage ingestion (periodic counters, not a raw-event firehose). + +## Models (`fusion.billing.*`) + +| Model | Purpose | +|---|---| +| `fusion.billing.service` | One source app; bearer API key (hashed) + webhook config. | +| `fusion.billing.account.link` | External account id → one `res.partner` (identity resolution). | +| `fusion.billing.metric` | Billable metric + aggregation (sum/max/last/unique). | +| `fusion.billing.charge` | Plan + metric → included quota + overage pricing. | +| `fusion.billing.usage` | Aggregated per-period usage rollups (idempotent). | +| `fusion.billing.webhook` | Outbound lifecycle event queue (HMAC + retry). | +| `fusion.billing.reconciliation` | Dual-run Odoo-vs-app delta during cutover. | + +> **Odoo 19 note (verified):** a subscription is a `sale.order` with `is_subscription=True` +> (`plan_id` → `sale.subscription.plan`). There is **no** `sale.subscription` model. +> `fusion.billing.usage.subscription_id` therefore points at `sale.order`. + +## Inbound API + +Lago-shaped REST under `/api/billing/v1/*`, bearer auth. Endpoints mirror NexaDesk's +existing `lago-client.ts` so migration is a thin client swap. `/health` works today; +the rest return `501` until implemented. + +## Relationship to `fusion_api` + +`fusion_api` manages **outbound** provider keys (OpenAI, Maps, Twilio) + cost tracking — +i.e. COGS. This module tracks **customer** revenue. Complementary: feed `fusion_api` +cost into margin reporting; reuse its daily-rollup aggregation pattern. + +## Dependencies + +`account_accountant`, `sale_subscription`, `sale_management`, `payment_stripe`. + +## Local dev + +```bash +docker exec odoo-nexa-app odoo -d nexamain -u fusion_centralize_billing --stop-after-init +# tests (once added): +docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing -u fusion_centralize_billing --stop-after-init +``` + +Canadian English, CAD, HST via `account.tax`. New fields on native models use the `x_fc_*` prefix. diff --git a/fusion_centralize_billing/__init__.py b/fusion_centralize_billing/__init__.py new file mode 100644 index 00000000..f7209b17 --- /dev/null +++ b/fusion_centralize_billing/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/fusion_centralize_billing/__manifest__.py b/fusion_centralize_billing/__manifest__.py new file mode 100644 index 00000000..6662ea80 --- /dev/null +++ b/fusion_centralize_billing/__manifest__.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +{ + "name": "Fusion Centralized Billing", + "version": "19.0.1.0.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.", + "description": """ +Fusion Centralized Billing +========================== + +Makes this Odoo Enterprise instance the single billing brain for every NexaSystems +service (NexaCloud hosting, NexaDesk chat, NexaMaps API, custom apps, memberships). + +It adds ONLY the metering + integration layer; all financial behaviour (invoicing, +HST tax, proration, dunning, portal, credit notes, Stripe) is native Odoo Enterprise. + +Capabilities +------------ +* Service registry — one record per source app (NexaCloud / NexaDesk / NexaMaps) with + bearer API key + webhook config. +* Identity links — fold each app's external account into one ``res.partner``. +* Metric + Charge catalog — billable metrics with quota + overage pricing, keyed by a + shared ``plan_code`` (apps own feature entitlements; Odoo owns money). +* Usage engine — aggregate-push: apps send periodic counters; a pre-invoice cron feeds + billable quantities onto the subscription ``sale.order``. +* Inbound API — Lago-shaped REST (``/api/billing/v1/*``), bearer auth. +* Outbound webhooks — HMAC-signed lifecycle events (payment failed/succeeded, + subscription terminated) so apps suspend / restore / deprovision. + +Design spec: docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md + +Status: SCAFFOLD. Model fields are in place; engine/API/webhook bodies are stubs to be +implemented via the writing-plans output. Per repo CLAUDE.md, read live Odoo 19 +reference files from the container before implementing subscription/account internals. + """, + "author": "Nexa Systems Inc.", + "website": "https://nexasystems.ca", + "license": "OPL-1", + "depends": [ + "account_accountant", + "sale_subscription", + "sale_management", + "payment_stripe", + ], + "data": [ + "security/ir.model.access.csv", + ], + "installable": True, + "application": False, + "auto_install": False, +} diff --git a/fusion_centralize_billing/controllers/__init__.py b/fusion_centralize_billing/controllers/__init__.py new file mode 100644 index 00000000..4dc7607e --- /dev/null +++ b/fusion_centralize_billing/controllers/__init__.py @@ -0,0 +1 @@ +from . import api diff --git a/fusion_centralize_billing/controllers/api.py b/fusion_centralize_billing/controllers/api.py new file mode 100644 index 00000000..d9001684 --- /dev/null +++ b/fusion_centralize_billing/controllers/api.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +"""Inbound, Lago-shaped billing API (spec §7). + +Auth: bearer API key matched (by SHA-256 hash) against ``fusion.billing.service``. +Routing: ``type="http"`` + ``auth="none"`` + ``csrf=False`` — external apps present +bearer tokens, not Odoo sessions (so NOT ``type="jsonrpc"``). + +STATUS: SCAFFOLD. Only auth + /health are wired. Endpoint bodies are stubs (HTTP 501) +to be implemented from the writing-plans output. Per repo CLAUDE.md, read live Odoo 19 +references (sale.order subscription flow, account.move, payment_stripe) before +implementing — do NOT code those internals from memory. +""" +import hashlib +import logging + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + +API_BASE = "/api/billing/v1" + + +class FusionBillingApi(http.Controller): + + # ── helpers ────────────────────────────────────────────────────────── + def _authenticate(self): + """Return the active fusion.billing.service for the bearer key, else None.""" + auth = request.httprequest.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return None + raw = auth[7:].strip() + if not raw: + return None + key_hash = hashlib.sha256(raw.encode()).hexdigest() + service = request.env["fusion.billing.service"].sudo().search( + [("api_key_hash", "=", key_hash), ("active", "=", True)], limit=1, + ) + return service or None + + def _json(self, payload, status=200): + return request.make_json_response(payload, status=status) + + # ── routes ─────────────────────────────────────────────────────────── + @http.route(f"{API_BASE}/health", type="http", auth="none", methods=["GET"], csrf=False) + def health(self, **kw): + return self._json({"status": "ok", "service": "fusion_centralize_billing"}) + + @http.route(f"{API_BASE}/usage", type="http", auth="none", methods=["POST"], csrf=False) + def post_usage(self, **kw): + """Hot path: batch aggregated usage counters. Returns 202 once implemented.""" + service = self._authenticate() + if not service: + return self._json({"error": "unauthorized"}, status=401) + # TODO(spec §6): idempotent upsert into fusion.billing.usage by idempotency_key. + return self._json({"error": "not_implemented"}, status=501) + + # TODO(spec §7): implement the remaining Lago-shaped endpoints, each gated by + # self._authenticate(): + # POST /customers upsert res.partner + account.link + # POST /subscriptions create subscription sale.order + # PUT /subscriptions/ change / upgrade + # DELETE /subscriptions/ cancel + # POST /invoices one-off invoice (token pack, throttle-removal) + # GET /invoices list (filter by external customer) + # GET /invoices/ fetch + # POST /invoices//download PDF + # POST /invoices//retry_payment retry + # POST /invoices//void void + # POST /credit_notes refund (account.move reversal) + # GET /plans catalog/pricing for the app + # POST /customers//checkout_url Stripe payment-method setup diff --git a/fusion_centralize_billing/models/__init__.py b/fusion_centralize_billing/models/__init__.py new file mode 100644 index 00000000..de3d5dfd --- /dev/null +++ b/fusion_centralize_billing/models/__init__.py @@ -0,0 +1,7 @@ +from . import service +from . import account_link +from . import metric +from . import charge +from . import usage +from . import webhook +from . import reconciliation diff --git a/fusion_centralize_billing/models/account_link.py b/fusion_centralize_billing/models/account_link.py new file mode 100644 index 00000000..66e3c1ed --- /dev/null +++ b/fusion_centralize_billing/models/account_link.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +from odoo import fields, models + + +class FusionBillingAccountLink(models.Model): + """Identity resolution: maps an app's external account id to one res.partner. + + Folds the NexaCloud user / NexaDesk tenant / NexaMaps client for the same + real-world client onto a single partner (the unified customer). See spec §5.1. + """ + + _name = "fusion.billing.account.link" + _description = "Fusion Billing — External Account → Partner Link" + _order = "service_id, external_id" + + service_id = fields.Many2one( + "fusion.billing.service", required=True, ondelete="cascade", index=True, + ) + external_id = fields.Char( + required=True, index=True, + help="The app's own account id (NexaCloud user, NexaDesk tenant, Maps client).", + ) + external_email = fields.Char() + partner_id = fields.Many2one( + "res.partner", required=True, ondelete="restrict", index=True, + ) + + _service_external_uniq = models.Constraint( + "unique(service_id, external_id)", + "An external account can only link to one partner per service.", + ) diff --git a/fusion_centralize_billing/models/charge.py b/fusion_centralize_billing/models/charge.py new file mode 100644 index 00000000..a194f38d --- /dev/null +++ b/fusion_centralize_billing/models/charge.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +from odoo import fields, models + + +class FusionBillingCharge(models.Model): + """Maps a plan + metric to quota + overage pricing. + + This is where "5,000,000 included / $0.10 per 1k overage" (NexaMaps) or a + NexaCloud CPU-seconds quota lives. Keyed by the shared ``plan_code`` the app + references; Odoo owns the money, the app owns feature entitlements. See spec §5.1. + """ + + _name = "fusion.billing.charge" + _description = "Fusion Billing — Metered Charge (quota + overage)" + _order = "plan_code, name" + + name = fields.Char(required=True) + plan_code = fields.Char( + required=True, index=True, + help="Shared plan_code the source app references (matches a sale.subscription.plan).", + ) + plan_id = fields.Many2one( + "sale.subscription.plan", + help="Optional link to the Odoo recurrence/plan for this charge.", + ) + metric_id = fields.Many2one( + "fusion.billing.metric", required=True, ondelete="restrict", + ) + product_id = fields.Many2one( + "product.product", help="Usage product invoiced for overage.", + ) + included_quota = fields.Float( + default=0.0, help="Units included before overage applies, per period.", + ) + price_per_unit = fields.Monetary(help="Overage price per unit_batch.") + unit_batch = fields.Float( + default=1.0, help="Batch size for overage pricing, e.g. 1000 = priced per 1k.", + ) + charge_model = fields.Selection( + [ + ("standard", "Standard (per unit)"), + ("graduated", "Graduated"), + ("package", "Package"), + ("volume", "Volume"), + ], + default="standard", required=True, + ) + currency_id = fields.Many2one( + "res.currency", default=lambda self: self.env.company.currency_id, + ) + active = fields.Boolean(default=True) diff --git a/fusion_centralize_billing/models/metric.py b/fusion_centralize_billing/models/metric.py new file mode 100644 index 00000000..b631e67d --- /dev/null +++ b/fusion_centralize_billing/models/metric.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +from odoo import fields, models + + +class FusionBillingMetric(models.Model): + """A billable metric (CPU-seconds, API calls, messages, tokens ...). + + Defines how raw usage is aggregated within a billing period. See spec §5.1 / §6. + """ + + _name = "fusion.billing.metric" + _description = "Fusion Billing — Billable Metric" + _order = "code" + + name = fields.Char(required=True) + code = fields.Char(required=True, index=True) + aggregation = fields.Selection( + [ + ("sum", "Sum"), + ("max", "Max"), + ("last", "Last value"), + ("unique_count", "Unique count"), + ], + default="sum", required=True, + ) + unit_label = fields.Char(help="e.g. CPU-seconds, API calls, messages, tokens.") + rounding = fields.Float(default=1.0) + active = fields.Boolean(default=True) + + _code_uniq = models.Constraint("unique(code)", "Metric code must be unique.") diff --git a/fusion_centralize_billing/models/reconciliation.py b/fusion_centralize_billing/models/reconciliation.py new file mode 100644 index 00000000..3805aae1 --- /dev/null +++ b/fusion_centralize_billing/models/reconciliation.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +from odoo import fields, models + + +class FusionBillingReconciliation(models.Model): + """Dual-run shadow-mode comparison: Odoo-computed vs the app's actual billing. + + During phased cutover (NexaCloud first), Odoo computes invoices while the app + keeps charging. This row records the per-customer, per-period delta so we only + flip once deltas are within tolerance. See spec §10. + """ + + _name = "fusion.billing.reconciliation" + _description = "Fusion Billing — Dual-Run Reconciliation" + _order = "period desc, service_id" + + service_id = fields.Many2one( + "fusion.billing.service", required=True, ondelete="cascade", index=True, + ) + 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.") + odoo_amount = fields.Monetary() + external_amount = fields.Monetary(string="App-actual Amount") + delta = fields.Monetary(help="odoo_amount - external_amount.") + currency_id = fields.Many2one( + "res.currency", default=lambda self: self.env.company.currency_id, + ) + status = fields.Selection( + [ + ("match", "Within tolerance"), + ("delta", "Delta — investigate"), + ("resolved", "Resolved"), + ], + default="delta", required=True, index=True, + ) + note = fields.Text() diff --git a/fusion_centralize_billing/models/service.py b/fusion_centralize_billing/models/service.py new file mode 100644 index 00000000..953eac7a --- /dev/null +++ b/fusion_centralize_billing/models/service.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +import hashlib +import secrets + +from odoo import api, fields, models + + +class FusionBillingService(models.Model): + """A source app that pushes billing data (NexaCloud / NexaDesk / NexaMaps). + + The bearer API key is shown ONCE on generation and stored only as a SHA-256 + hash. This record is the auth + routing boundary for the inbound API and the + target for outbound webhooks. See spec §5.1 / §7 / §8. + """ + + _name = "fusion.billing.service" + _description = "Fusion Billing — Source Service" + _order = "name" + + name = fields.Char(required=True) + code = fields.Char( + required=True, index=True, + help="Stable code the app identifies itself with, e.g. nexacloud / nexadesk / nexamaps.", + ) + active = fields.Boolean(default=True) + + api_key_hash = fields.Char( + string="API Key (SHA-256)", + help="Hash of the bearer key. The raw key is displayed once at generation time.", + ) + webhook_url = fields.Char(help="Endpoint this app exposes to receive billing webhooks.") + webhook_secret = fields.Char(help="Shared secret for HMAC-SHA256 webhook signatures.") + + account_link_ids = fields.One2many( + "fusion.billing.account.link", "service_id", string="Customer Links", + ) + account_link_count = fields.Integer(compute="_compute_account_link_count") + + _code_uniq = models.Constraint("unique(code)", "Service code must be unique.") + + @api.depends("account_link_ids") + def _compute_account_link_count(self): + for rec in self: + rec.account_link_count = len(rec.account_link_ids) + + def action_generate_api_key(self): + """Generate a fresh bearer key, store only its hash, return the raw key. + + TODO(spec §7): surface the raw key once in the UI (wizard/notification). + """ + self.ensure_one() + raw = secrets.token_urlsafe(32) + self.api_key_hash = hashlib.sha256(raw.encode()).hexdigest() + return raw diff --git a/fusion_centralize_billing/models/usage.py b/fusion_centralize_billing/models/usage.py new file mode 100644 index 00000000..f8e12658 --- /dev/null +++ b/fusion_centralize_billing/models/usage.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +from odoo import fields, models + + +class FusionBillingUsage(models.Model): + """Aggregated usage rollup for a (subscription, metric, period). + + Aggregate-push model: apps send periodic counters (not raw events). The + ``idempotency_key`` makes re-sent counters safe — they never double-count. + A pre-invoice cron sums these and feeds billable quantity onto the subscription. + + NOTE (Odoo 19, verified): the subscription is a ``sale.order`` with + ``is_subscription=True`` — there is no ``sale.subscription`` model. See spec §5.2. + """ + + _name = "fusion.billing.usage" + _description = "Fusion Billing — Aggregated Usage (period rollup)" + _order = "period_start desc" + + subscription_id = fields.Many2one( + "sale.order", required=True, ondelete="cascade", index=True, + string="Subscription", domain=[("is_subscription", "=", True)], + ) + metric_id = fields.Many2one( + "fusion.billing.metric", required=True, ondelete="restrict", index=True, + ) + period_start = fields.Datetime(required=True) + period_end = fields.Datetime(required=True) + quantity = fields.Float(default=0.0) + source = fields.Char(default="push") + idempotency_key = fields.Char( + index=True, help="Dedupe key so re-sent counters never double-count.", + ) + + _idempotency_uniq = models.Constraint( + "unique(idempotency_key)", "Usage idempotency key must be unique.", + ) diff --git a/fusion_centralize_billing/models/webhook.py b/fusion_centralize_billing/models/webhook.py new file mode 100644 index 00000000..14b2ea81 --- /dev/null +++ b/fusion_centralize_billing/models/webhook.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +from odoo import fields, models + + +class FusionBillingWebhook(models.Model): + """Outbound webhook queue: lifecycle events delivered to source apps. + + Processed by a cron with exponential backoff + HMAC-SHA256 signing, dead-lettered + after N attempts (mirror the proven retry pattern in NexaDesk's + lago-payment-retry-job). Apps react: suspend / restore / deprovision. See spec §8. + + TODO(spec §8): cron processor, HMAC signing, backoff schedule. + """ + + _name = "fusion.billing.webhook" + _description = "Fusion Billing — Outbound Webhook Event" + _order = "create_date desc" + + service_id = fields.Many2one( + "fusion.billing.service", required=True, ondelete="cascade", index=True, + ) + event_type = fields.Char( + required=True, index=True, + help="invoice.payment_failed / invoice.payment_succeeded / " + "subscription.terminated / subscription.reactivated / usage.threshold_reached", + ) + payload = fields.Json() + state = fields.Selection( + [ + ("pending", "Pending"), + ("sent", "Sent"), + ("failed", "Failed"), + ("dead", "Dead-lettered"), + ], + default="pending", required=True, index=True, + ) + attempts = fields.Integer(default=0) + next_retry_at = fields.Datetime() + signature = fields.Char(help="HMAC-SHA256 of the payload using the service webhook_secret.") + last_error = fields.Text() diff --git a/fusion_centralize_billing/security/ir.model.access.csv b/fusion_centralize_billing/security/ir.model.access.csv new file mode 100644 index 00000000..a7d909ca --- /dev/null +++ b/fusion_centralize_billing/security/ir.model.access.csv @@ -0,0 +1,11 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fusion_billing_service_admin,fusion.billing.service admin,model_fusion_billing_service,base.group_system,1,1,1,1 +access_fusion_billing_account_link_admin,fusion.billing.account.link admin,model_fusion_billing_account_link,base.group_system,1,1,1,1 +access_fusion_billing_metric_admin,fusion.billing.metric admin,model_fusion_billing_metric,base.group_system,1,1,1,1 +access_fusion_billing_charge_admin,fusion.billing.charge admin,model_fusion_billing_charge,base.group_system,1,1,1,1 +access_fusion_billing_usage_admin,fusion.billing.usage admin,model_fusion_billing_usage,base.group_system,1,1,1,1 +access_fusion_billing_webhook_admin,fusion.billing.webhook admin,model_fusion_billing_webhook,base.group_system,1,1,1,1 +access_fusion_billing_reconciliation_admin,fusion.billing.reconciliation admin,model_fusion_billing_reconciliation,base.group_system,1,1,1,1 +access_fusion_billing_metric_acct,fusion.billing.metric accountant,model_fusion_billing_metric,account.group_account_manager,1,1,1,0 +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 From e7d63a38596ee1275083f1e73f0e35d2b1dbda16 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 02:24:19 -0400 Subject: [PATCH 02/37] docs(billing): core engine implementation plan (TDD, 11 tasks) --- ...26-05-27-fusion-centralize-billing-core.md | 1102 +++++++++++++++++ 1 file changed, 1102 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md diff --git a/docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md b/docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md new file mode 100644 index 00000000..b73f3115 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md @@ -0,0 +1,1102 @@ +# fusion_centralize_billing — Core Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the product-agnostic core of `fusion_centralize_billing` — service registry + API-key auth, identity resolution, the metered-charge math, idempotent usage ingestion + aggregation, the Lago-shaped inbound API, and the outbound webhook engine — on Odoo 19 Enterprise. + +**Architecture:** Thin HTTP controllers (`type="http"`, bearer auth) delegate to **model methods** that hold all logic (so everything is unit-testable under `TransactionCase` without an HTTP server). Usage arrives as pre-aggregated counters, is stored idempotently, and a cron rates it against `fusion.billing.charge` (quota + overage). Native `sale_subscription`/`account_accountant`/`payment_stripe` do invoicing/tax/payment. Lifecycle events are queued in `fusion.billing.webhook` and dispatched by a cron with HMAC signing + exponential backoff. + +**Tech Stack:** Odoo 19 Enterprise (Python 3.12), `sale_subscription`, `account_accountant`, `payment_stripe`. Tests: `odoo.tests.common.TransactionCase`. + +**Scope:** Core engine only. The **NexaCloud adapter + dual-run reconciliation** is a separate follow-on plan (it depends on this core). The scaffold already exists and compiles (`fusion_centralize_billing/`: 7 `fusion.billing.*` models, API controller shell, security, README). + +**Spec:** `docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md` + +--- + +## Conventions for every task + +- **Never code Odoo internals from memory** (repo CLAUDE.md rule #1). Where a step says "read reference", run the exact `docker exec` shown and confirm field/method names before implementing. +- **Models, not services:** business logic lives in model methods named `_api_*` (request handlers) or `_*` (helpers). Controllers only parse → call → JSON. +- **Money:** CAD, `Monetary` with a `currency_id`. Quantities are `Float`. +- **SQL constraints/indexes:** declarative `models.Constraint` / `models.Index` only (rule #9). Never `_sql_constraints`. +- **New fields on native models:** `x_fc_*` prefix. +- **Registering tests:** when a task creates a new `tests/test_*.py`, append its `from . import test_*` line to `tests/__init__.py` and include `__init__.py` in that task's commit. This keeps the module importable at every task boundary (the `__init__.py` must never import a file that doesn't exist yet). + +## Test environment + +The module depends on Enterprise modules, so tests run on an instance that has them. Use the dev container (it bind-mounts `Odoo-Modules` at `/mnt/odoo-modules`): + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev \ + -i fusion_centralize_billing --test-enable \ + --test-tags /fusion_centralize_billing --stop-after-init +``` + +- First run uses `-i` (install); later runs use `-u` (upgrade). +- **Never** run `--test-enable -u` against production `nexamain`. If the dev instance lacks Enterprise addons, provision a throwaway test DB on `odoo-nexa` and run there. +- A task "passes" when the run ends with `0 failed, 0 error(s)` for the module's tags. + +## File structure (this plan) + +``` +fusion_centralize_billing/ + models/ + service.py # +_match_api_key, +_api_* request handlers + account_link.py # +_resolve_or_create_partner + metric.py # (definition only) + charge.py # +_compute_billable (quota + overage math — the billing core) + usage.py # +_record_usage (idempotent), +_aggregate, +_cron_rate_open_periods + webhook.py # +_enqueue, +_sign, +_cron_dispatch + reconciliation.py # (untouched here — NexaCloud-adapter plan) + controllers/ + api.py # implement endpoints; each delegates to a model _api_* method + data/ + ir_cron.xml # NEW: usage-rating cron + webhook-dispatch cron + tests/ + __init__.py # NEW + test_charge.py # NEW + test_usage.py # NEW + test_identity.py # NEW + test_api.py # NEW + test_webhook.py # NEW + __manifest__.py # add data/ir_cron.xml; add 'tests' is implicit +``` + +--- + +## Task 1: Test scaffolding + service API-key matching + +**Files:** +- Create: `fusion_centralize_billing/tests/__init__.py` +- Create: `fusion_centralize_billing/tests/test_identity.py` +- Modify: `fusion_centralize_billing/models/service.py` + +- [ ] **Step 1: Create the tests package** + +`fusion_centralize_billing/tests/__init__.py` (start with ONLY the module that exists now; later test tasks append their own line per the "Registering tests" convention): +```python +from . import test_identity +``` + +- [ ] **Step 2: Write the failing test** + +`fusion_centralize_billing/tests/test_identity.py`: +```python +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestServiceApiKey(TransactionCase): + + def setUp(self): + super().setUp() + self.Service = self.env['fusion.billing.service'].sudo() + self.service = self.Service.create({'name': 'NexaCloud', 'code': 'nexacloud'}) + + def test_generate_and_match_api_key(self): + raw = self.service.action_generate_api_key() + self.assertTrue(raw and len(raw) >= 20) + self.assertTrue(self.service.api_key_hash) + self.assertNotEqual(raw, self.service.api_key_hash) # only the hash is stored + matched = self.Service._match_api_key(raw) + self.assertEqual(matched, self.service) + + def test_match_api_key_rejects_unknown_and_inactive(self): + raw = self.service.action_generate_api_key() + self.assertFalse(self.Service._match_api_key('nope-not-a-key')) + self.service.active = False + self.assertFalse(self.Service._match_api_key(raw)) +``` + +- [ ] **Step 3: Run it, expect failure** + +Run: `docker exec odoo-modsdev-app odoo -d fusion-dev -i fusion_centralize_billing --test-enable --test-tags /fusion_centralize_billing --stop-after-init` +Expected: FAIL — `_match_api_key` does not exist. + +- [ ] **Step 4: Implement `_match_api_key`** + +In `models/service.py`, add the method to `FusionBillingService` (the `hashlib`/`secrets` imports already exist): +```python + @api.model + def _match_api_key(self, raw_key): + """Return the active service whose stored hash matches raw_key, else empty recordset.""" + if not raw_key: + return self.browse() + key_hash = hashlib.sha256(raw_key.encode()).hexdigest() + return self.search([('api_key_hash', '=', key_hash), ('active', '=', True)], limit=1) +``` + +- [ ] **Step 5: Run it, expect pass** + +Run: same command as Step 3. +Expected: PASS for `TestServiceApiKey`. + +- [ ] **Step 6: Commit** + +```bash +git add fusion_centralize_billing/tests/__init__.py fusion_centralize_billing/tests/test_identity.py fusion_centralize_billing/models/service.py +git commit -m "feat(billing): service API-key generation + matching" +``` + +--- + +## Task 2: Identity resolution (external account → res.partner) + +**Files:** +- Modify: `fusion_centralize_billing/models/account_link.py` +- Modify: `fusion_centralize_billing/tests/test_identity.py` + +- [ ] **Step 1: Write the failing test** (append to `test_identity.py`) + +```python +@tagged('post_install', '-at_install') +class TestIdentityResolution(TransactionCase): + + def setUp(self): + super().setUp() + self.service = self.env['fusion.billing.service'].sudo().create( + {'name': 'NexaDesk', 'code': 'nexadesk'}) + self.Link = self.env['fusion.billing.account.link'].sudo() + + def test_creates_partner_first_time(self): + link = self.Link._resolve_or_create_partner( + self.service, external_id='tenant-1', name='Acme Inc', email='ar@acme.test') + self.assertTrue(link.partner_id) + self.assertEqual(link.partner_id.name, 'Acme Inc') + self.assertEqual(link.external_id, 'tenant-1') + + def test_idempotent_same_external_id(self): + a = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme', 'ar@acme.test') + b = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme Renamed', 'ar@acme.test') + self.assertEqual(a, b) # same link row + self.assertEqual(a.partner_id, b.partner_id) # same partner + + def test_reuses_partner_by_email_across_services(self): + other = self.env['fusion.billing.service'].sudo().create({'name': 'Maps', 'code': 'nexamaps'}) + a = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme', 'ar@acme.test') + b = self.Link._resolve_or_create_partner(other, 'client-9', 'Acme', 'ar@acme.test') + self.assertEqual(a.partner_id, b.partner_id) # one unified customer + self.assertNotEqual(a, b) # but distinct link rows +``` + +- [ ] **Step 2: Run it, expect failure** + +Run: `... --test-tags /fusion_centralize_billing ...` +Expected: FAIL — `_resolve_or_create_partner` not defined. + +- [ ] **Step 3: Implement the resolver** + +In `models/account_link.py`, add `api` to the import (`from odoo import api, fields, models`) and add: +```python + @api.model + def _resolve_or_create_partner(self, service, external_id, name=None, email=None, extra=None): + """Return the link for (service, external_id), creating partner+link if needed. + + Unifies customers: if a link for this external_id exists, reuse it; else if a + partner with the same email already exists (possibly from another service), + link to it; else create a new partner. + """ + existing = self.search( + [('service_id', '=', service.id), ('external_id', '=', external_id)], limit=1) + if existing: + return existing + partner = self.env['res.partner'] + if email: + partner = partner.search([('email', '=', email)], limit=1) + if not partner: + partner = partner.create({'name': name or external_id, 'email': email, **(extra or {})}) + return self.create({ + 'service_id': service.id, + 'external_id': external_id, + 'external_email': email, + 'partner_id': partner.id, + }) +``` + +- [ ] **Step 4: Run it, expect pass** + +Run: same command. +Expected: PASS for `TestIdentityResolution`. + +- [ ] **Step 5: Commit** + +```bash +git add fusion_centralize_billing/models/account_link.py fusion_centralize_billing/tests/test_identity.py +git commit -m "feat(billing): identity resolution external account -> partner" +``` + +--- + +## Task 3: Metered-charge math (quota + overage) + +This is the billing core — pure, deterministic, heavily tested. + +**Files:** +- Modify: `fusion_centralize_billing/models/charge.py` +- Create: `fusion_centralize_billing/tests/test_charge.py` + +- [ ] **Step 1: Write the failing test** + +`fusion_centralize_billing/tests/test_charge.py`: +```python +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestChargeMath(TransactionCase): + + def setUp(self): + super().setUp() + self.metric = self.env['fusion.billing.metric'].sudo().create( + {'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'}) + + def _charge(self, **kw): + vals = { + 'name': 'Maps overage', 'plan_code': 'maps-business', + 'metric_id': self.metric.id, 'charge_model': 'standard', + 'included_quota': 5_000_000.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0, + } + vals.update(kw) + return self.env['fusion.billing.charge'].sudo().create(vals) + + def test_under_quota_is_free(self): + charge = self._charge() + overage_units, amount = charge._compute_billable(4_000_000.0) + self.assertEqual(overage_units, 0.0) + self.assertEqual(amount, 0.0) + + def test_standard_overage_per_1k(self): + charge = self._charge() + # 6,000,000 used - 5,000,000 quota = 1,000,000 overage = 1000 batches * $0.10 + overage_units, amount = charge._compute_billable(6_000_000.0) + self.assertEqual(overage_units, 1_000_000.0) + self.assertAlmostEqual(amount, 100.0, places=2) + + def test_partial_batch_rounds_up(self): + charge = self._charge(included_quota=0.0) + # 1,500 units, batch 1000 -> 2 batches -> $0.20 + _, amount = charge._compute_billable(1_500.0) + self.assertAlmostEqual(amount, 0.20, places=2) + + def test_package_model_charges_whole_packages(self): + charge = self._charge(charge_model='package', included_quota=0.0, unit_batch=1000.0, price_per_unit=2.0) + # 2,001 units -> 3 packages -> $6.00 + _, amount = charge._compute_billable(2_001.0) + self.assertAlmostEqual(amount, 6.0, places=2) +``` + +- [ ] **Step 2: Run it, expect failure** + +Expected: FAIL — `_compute_billable` not defined. + +- [ ] **Step 3: Implement the math** + +In `models/charge.py` add `import math` and `from odoo import api, fields, models`, then: +```python + def _compute_billable(self, total_quantity): + """Return (overage_units, amount) for total period usage under this charge. + + - overage_units = usage above included_quota (never negative) + - 'standard'/'package'/'volume': priced per `unit_batch` block, partial block rounds up. + (graduated tiers are out of scope for the core; treated as 'standard'.) + """ + self.ensure_one() + overage = max(0.0, (total_quantity or 0.0) - (self.included_quota or 0.0)) + batch = self.unit_batch or 1.0 + if self.charge_model == 'package': + # whole packages over the RAW quantity (quota ignored for package counting) + blocks = math.ceil((total_quantity or 0.0) / batch) if total_quantity else 0 + return overage, round(blocks * (self.price_per_unit or 0.0), 2) + # standard / volume / graduated-fallback: price the overage in (rounded-up) batches + blocks = math.ceil(overage / batch) if overage > 0 else 0 + return overage, round(blocks * (self.price_per_unit or 0.0), 2) +``` + +- [ ] **Step 4: Run it, expect pass** + +Expected: PASS for `TestChargeMath` (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add fusion_centralize_billing/models/charge.py fusion_centralize_billing/tests/test_charge.py fusion_centralize_billing/tests/__init__.py +git commit -m "feat(billing): metered charge math (quota + overage)" +``` + +--- + +## Task 4: Idempotent usage ingestion + +**Files:** +- Modify: `fusion_centralize_billing/models/usage.py` +- Create: `fusion_centralize_billing/tests/test_usage.py` + +- [ ] **Step 1: Write the failing test** + +`fusion_centralize_billing/tests/test_usage.py`: +```python +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestUsageIngestion(TransactionCase): + + def setUp(self): + super().setUp() + self.metric = self.env['fusion.billing.metric'].sudo().create( + {'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'}) + self.plan = self.env['sale.subscription.plan'].sudo().create( + {'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'}) + self.partner = self.env['res.partner'].sudo().create({'name': 'Acme'}) + self.sub = self.env['sale.order'].sudo().create({ + 'partner_id': self.partner.id, 'is_subscription': True, 'plan_id': self.plan.id, + }) + self.Usage = self.env['fusion.billing.usage'].sudo() + + def test_record_usage_creates_row(self): + u = self.Usage._record_usage( + self.sub, 'cpu_seconds', 120.0, + '2026-05-01 00:00:00', '2026-06-01 00:00:00', idem='nexacloud:cpu:sub1:2026-05-01') + self.assertEqual(u.quantity, 120.0) + self.assertEqual(u.metric_id, self.metric) + + def test_idempotent_key_updates_not_duplicates(self): + k = 'nexacloud:cpu:sub1:2026-05-01' + self.Usage._record_usage(self.sub, 'cpu_seconds', 100.0, '2026-05-01', '2026-06-01', idem=k) + self.Usage._record_usage(self.sub, 'cpu_seconds', 175.0, '2026-05-01', '2026-06-01', idem=k) + rows = self.Usage.search([('idempotency_key', '=', k)]) + self.assertEqual(len(rows), 1) # no duplicate + self.assertEqual(rows.quantity, 175.0) # last value wins for the same key +``` + +- [ ] **Step 2: Run it, expect failure** + +Expected: FAIL — `_record_usage` not defined. + +- [ ] **Step 3: Implement ingestion** + +In `models/usage.py` add `from odoo import api, fields, models` (keep existing) and: +```python + @api.model + def _record_usage(self, subscription, metric_code, quantity, period_start, period_end, idem=None): + """Upsert one aggregated usage row. Same idempotency key updates in place (no double-count).""" + metric = self.env['fusion.billing.metric'].search([('code', '=', metric_code)], limit=1) + if not metric: + raise ValueError("Unknown metric code: %s" % metric_code) + vals = { + 'subscription_id': subscription.id, + 'metric_id': metric.id, + 'period_start': period_start, + 'period_end': period_end, + 'quantity': quantity, + 'idempotency_key': idem, + } + if idem: + existing = self.search([('idempotency_key', '=', idem)], limit=1) + if existing: + existing.write({'quantity': quantity}) + return existing + return self.create(vals) +``` + +- [ ] **Step 4: Run it, expect pass** + +Expected: PASS for `TestUsageIngestion`. + +- [ ] **Step 5: Commit** + +```bash +git add fusion_centralize_billing/models/usage.py fusion_centralize_billing/tests/test_usage.py fusion_centralize_billing/tests/__init__.py +git commit -m "feat(billing): idempotent usage ingestion" +``` + +--- + +## Task 5: Usage aggregation per period + +**Files:** +- Modify: `fusion_centralize_billing/models/usage.py` +- Modify: `fusion_centralize_billing/tests/test_usage.py` + +- [ ] **Step 1: Write the failing test** (append to `test_usage.py`) + +```python + def test_aggregate_sum(self): + for i, q in enumerate([10.0, 20.0, 30.0]): + self.Usage._record_usage(self.sub, 'cpu_seconds', q, + '2026-05-01', '2026-06-01', idem='cpu-%d' % i) + total = self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01') + self.assertEqual(total, 60.0) + + def test_aggregate_max(self): + self.metric.aggregation = 'max' + for i, q in enumerate([10.0, 55.0, 30.0]): + self.Usage._record_usage(self.sub, 'cpu_seconds', q, + '2026-05-01', '2026-06-01', idem='m-%d' % i) + self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 55.0) + + def test_aggregate_excludes_other_periods(self): + self.Usage._record_usage(self.sub, 'cpu_seconds', 99.0, '2026-04-01', '2026-05-01', idem='apr') + self.Usage._record_usage(self.sub, 'cpu_seconds', 5.0, '2026-05-01', '2026-06-01', idem='may') + self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 5.0) +``` + +- [ ] **Step 2: Run it, expect failure** + +Expected: FAIL — `_aggregate` not defined. + +- [ ] **Step 3: Implement aggregation** + +In `models/usage.py`: +```python + @api.model + def _aggregate(self, subscription, metric, period_start, period_end): + """Aggregate stored usage for a subscription+metric within [period_start, period_end) + using the metric's aggregation function.""" + rows = self.search([ + ('subscription_id', '=', subscription.id), + ('metric_id', '=', metric.id), + ('period_start', '>=', period_start), + ('period_end', '<=', period_end), + ]) + qtys = rows.mapped('quantity') + if not qtys: + return 0.0 + agg = metric.aggregation + if agg == 'sum': + return sum(qtys) + if agg == 'max': + return max(qtys) + if agg == 'last': + return rows.sorted('period_start')[-1].quantity + if agg == 'unique_count': + return float(len(set(qtys))) + return sum(qtys) +``` + +- [ ] **Step 4: Run it, expect pass** + +Expected: PASS for the three new tests. + +- [ ] **Step 5: Commit** + +```bash +git add fusion_centralize_billing/models/usage.py fusion_centralize_billing/tests/test_usage.py +git commit -m "feat(billing): period usage aggregation by metric function" +``` + +--- + +## Task 6: Inbound API handlers (customers + usage + catalog) as model methods + +Logic in `_api_*` methods (testable); controllers wired in Task 9. + +**Files:** +- Modify: `fusion_centralize_billing/models/service.py` +- Create: `fusion_centralize_billing/tests/test_api.py` +- Create empty `fusion_centralize_billing/tests/test_webhook.py` (filled in Task 8) so the package import in Task 1 resolves. + +- [ ] **Step 1: Register the API test module** + +Append to `fusion_centralize_billing/tests/__init__.py`: +```python +from . import test_api +``` + +- [ ] **Step 2: Write the failing test** + +`fusion_centralize_billing/tests/test_api.py`: +```python +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestApiHandlers(TransactionCase): + + def setUp(self): + super().setUp() + self.service = self.env['fusion.billing.service'].sudo().create( + {'name': 'NexaMaps', 'code': 'nexamaps'}) + self.env['fusion.billing.metric'].sudo().create( + {'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'}) + self.plan = self.env['sale.subscription.plan'].sudo().create( + {'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'}) + + def test_api_upsert_customer(self): + res = self.service._api_upsert_customer( + {'external_id': 'client-9', 'name': 'Globex', 'email': 'billing@globex.test'}) + self.assertEqual(res['status'], 'ok') + link = self.env['fusion.billing.account.link'].search( + [('service_id', '=', self.service.id), ('external_id', '=', 'client-9')]) + self.assertEqual(link.partner_id.name, 'Globex') + + def test_api_record_usage_batch(self): + self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'}) + partner = self.env['fusion.billing.account.link'].search( + [('external_id', '=', 'client-9')]).partner_id + sub = self.env['sale.order'].sudo().create( + {'partner_id': partner.id, 'is_subscription': True, 'plan_id': self.plan.id}) + res = self.service._api_record_usage({'events': [{ + 'subscription_external_id': str(sub.id), 'metric_code': 'api_calls', + 'quantity': 1234.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01', + 'idempotency_key': 'maps:client-9:2026-05-01', + }]}) + self.assertEqual(res['accepted'], 1) + usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)]) + self.assertEqual(usage.quantity, 1234.0) + + def test_api_catalog_lists_active_charges(self): + self.env['fusion.billing.charge'].sudo().create({ + 'name': 'Maps overage', 'plan_code': 'maps-business', + 'metric_id': self.env['fusion.billing.metric'].search([('code', '=', 'api_calls')]).id, + 'included_quota': 5_000_000.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0}) + cat = self.service._api_catalog() + codes = [c['plan_code'] for c in cat['charges']] + self.assertIn('maps-business', codes) +``` + +- [ ] **Step 3: Run it, expect failure** + +Expected: FAIL — `_api_upsert_customer` not defined. + +- [ ] **Step 4: Implement the handlers** + +In `models/service.py` (add `from odoo import api, fields, models` if not already importing `api`): +```python + def _api_upsert_customer(self, payload): + self.ensure_one() + ext = payload.get('external_id') + if not ext: + return {'status': 'error', 'error': 'external_id required'} + link = self.env['fusion.billing.account.link']._resolve_or_create_partner( + self, ext, name=payload.get('name'), email=payload.get('email')) + return {'status': 'ok', 'partner_id': link.partner_id.id, 'external_id': ext} + + def _api_record_usage(self, payload): + self.ensure_one() + events = payload.get('events') or [] + Usage = self.env['fusion.billing.usage'] + accepted = 0 + for ev in events: + sub = self.env['sale.order'].browse(int(ev['subscription_external_id'])) + Usage._record_usage( + sub, ev['metric_code'], float(ev['quantity']), + ev['period_start'], ev['period_end'], idem=ev.get('idempotency_key')) + accepted += 1 + return {'status': 'ok', 'accepted': accepted} + + def _api_catalog(self): + self.ensure_one() + charges = self.env['fusion.billing.charge'].search([('active', '=', True)]) + return {'status': 'ok', 'charges': [{ + 'plan_code': c.plan_code, 'metric': c.metric_id.code, + 'included_quota': c.included_quota, 'price_per_unit': c.price_per_unit, + 'unit_batch': c.unit_batch, 'charge_model': c.charge_model, + } for c in charges]} +``` + +- [ ] **Step 5: Run it, expect pass** + +Expected: PASS for `TestApiHandlers` (3 tests). + +- [ ] **Step 6: Commit** + +```bash +git add fusion_centralize_billing/models/service.py fusion_centralize_billing/tests/test_api.py fusion_centralize_billing/tests/__init__.py +git commit -m "feat(billing): inbound API handlers (customer/usage/catalog)" +``` + +--- + +## Task 7: Subscription creation handler + +**Read reference first** (do not code subscription creation from memory): +```bash +docker exec odoo-nexa-app bash -lc "grep -nE 'def action_confirm|is_subscription|def _portal_ensure_token|plan_id' /mnt/enterprise-addons/sale_subscription/models/sale_order.py | head" +``` +Confirm: a subscription is a `sale.order` with `is_subscription=True`, `plan_id`, and is activated via `action_confirm()` (sets `subscription_state='3_progress'`, computes `next_invoice_date`). + +**Files:** +- Modify: `fusion_centralize_billing/models/service.py` +- Modify: `fusion_centralize_billing/tests/test_api.py` + +- [ ] **Step 1: Write the failing test** (append to `test_api.py`) + +```python + def test_api_create_subscription(self): + self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'}) + product = self.env['product.product'].sudo().create( + {'name': 'Maps Business', 'type': 'service', 'list_price': 249.0}) + res = self.service._api_create_subscription({ + 'external_customer_id': 'client-9', + 'plan_id': self.plan.id, + 'lines': [{'product_id': product.id, 'quantity': 1}], + }) + self.assertEqual(res['status'], 'ok') + sub = self.env['sale.order'].browse(res['subscription_id']) + self.assertTrue(sub.is_subscription) + self.assertEqual(sub.plan_id, self.plan) + self.assertEqual(sub.subscription_state, '3_progress') +``` + +- [ ] **Step 2: Run it, expect failure** + +Expected: FAIL — `_api_create_subscription` not defined. + +- [ ] **Step 3: Implement subscription creation** + +In `models/service.py`: +```python + def _api_create_subscription(self, payload): + self.ensure_one() + link = self.env['fusion.billing.account.link'].search([ + ('service_id', '=', self.id), + ('external_id', '=', payload.get('external_customer_id')), + ], limit=1) + if not link: + return {'status': 'error', 'error': 'unknown customer'} + order_lines = [(0, 0, { + 'product_id': line['product_id'], + 'product_uom_qty': line.get('quantity', 1), + }) for line in payload.get('lines', [])] + sub = self.env['sale.order'].create({ + 'partner_id': link.partner_id.id, + 'is_subscription': True, + 'plan_id': payload['plan_id'], + 'order_line': order_lines, + }) + sub.action_confirm() + return {'status': 'ok', 'subscription_id': sub.id, + 'subscription_state': sub.subscription_state} +``` + +- [ ] **Step 4: Run it, expect pass** + +Expected: PASS. If `action_confirm()` raises about a missing pricelist/journal on the fresh `fusion-dev` DB, set the partner's `property_product_pricelist` in the test setUp (add `self.env.ref('product.list0')` if present) — do NOT weaken the assertion. + +- [ ] **Step 5: Commit** + +```bash +git add fusion_centralize_billing/models/service.py fusion_centralize_billing/tests/test_api.py +git commit -m "feat(billing): subscription creation handler (sale.order is_subscription)" +``` + +--- + +## Task 8: Outbound webhook engine (enqueue + HMAC + dispatch with retry) + +**Files:** +- Modify: `fusion_centralize_billing/models/webhook.py` +- Modify: `fusion_centralize_billing/tests/test_webhook.py` + +- [ ] **Step 1: Write the failing test** + +Create `fusion_centralize_billing/tests/test_webhook.py` (and append `from . import test_webhook` to `tests/__init__.py`): +```python +# -*- coding: utf-8 -*- +import hashlib +import hmac +import json +from unittest.mock import patch + +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestWebhookEngine(TransactionCase): + + def setUp(self): + super().setUp() + self.service = self.env['fusion.billing.service'].sudo().create({ + 'name': 'NexaCloud', 'code': 'nexacloud', + 'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook', + 'webhook_secret': 'whsec_test', + }) + self.Webhook = self.env['fusion.billing.webhook'].sudo() + + def test_enqueue_signs_payload(self): + wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-1'}) + self.assertEqual(wh.state, 'pending') + body = json.dumps({'invoice': 'INV-1'}, sort_keys=True, separators=(',', ':')) + expected = hmac.new(b'whsec_test', body.encode(), hashlib.sha256).hexdigest() + self.assertEqual(wh.signature, expected) + + def test_dispatch_marks_sent_on_2xx(self): + wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-2'}) + + class _Resp: + status_code = 200 + text = 'ok' + + with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post', + return_value=_Resp()) as mock_post: + self.Webhook._cron_dispatch() + self.assertTrue(mock_post.called) + self.assertEqual(wh.state, 'sent') + + def test_dispatch_retries_then_deadletters(self): + wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-3'}) + wh.write({'attempts': 7}) # already past max + + class _Resp: + status_code = 500 + text = 'err' + + with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post', + return_value=_Resp()): + self.Webhook._cron_dispatch() + self.assertEqual(wh.state, 'dead') +``` + +- [ ] **Step 2: Run it, expect failure** + +Expected: FAIL — `_enqueue`/`_cron_dispatch` not defined. + +- [ ] **Step 3: Implement the webhook engine** + +In `models/webhook.py` add imports and methods: +```python +import hashlib +import hmac +import json +import logging +from datetime import timedelta + +import requests + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + +MAX_ATTEMPTS = 8 +``` +Add to `FusionBillingWebhook`: +```python + @api.model + def _sign(self, secret, body): + return hmac.new((secret or '').encode(), body.encode(), hashlib.sha256).hexdigest() + + @api.model + def _enqueue(self, service, event_type, payload): + body = json.dumps(payload, sort_keys=True, separators=(',', ':')) + return self.create({ + 'service_id': service.id, + 'event_type': event_type, + 'payload': payload, + 'signature': self._sign(service.webhook_secret, body), + 'state': 'pending', + 'next_retry_at': fields.Datetime.now(), + }) + + @api.model + def _cron_dispatch(self): + now = fields.Datetime.now() + due = self.search([ + ('state', 'in', ('pending', 'failed')), + ('next_retry_at', '<=', now), + ], limit=100) + for wh in due: + body = json.dumps(wh.payload, sort_keys=True, separators=(',', ':')) + try: + resp = requests.post( + wh.service_id.webhook_url, + data=body, + headers={'Content-Type': 'application/json', + 'X-Fusion-Signature': wh.signature, + 'X-Fusion-Event': wh.event_type}, + timeout=10, + ) + ok = 200 <= resp.status_code < 300 + except Exception as e: # noqa: BLE001 - record and retry + ok = False + wh.last_error = str(e)[:500] + wh.attempts += 1 + if ok: + wh.state = 'sent' + elif wh.attempts >= MAX_ATTEMPTS: + wh.state = 'dead' + else: + wh.state = 'failed' + wh.next_retry_at = now + timedelta(minutes=2 ** wh.attempts) +``` + +- [ ] **Step 4: Run it, expect pass** + +Expected: PASS for `TestWebhookEngine` (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add fusion_centralize_billing/models/webhook.py fusion_centralize_billing/tests/test_webhook.py fusion_centralize_billing/tests/__init__.py +git commit -m "feat(billing): outbound webhook engine (HMAC + retry/backoff)" +``` + +--- + +## Task 9: Wire the HTTP controllers to the handlers + +**Files:** +- Modify: `fusion_centralize_billing/controllers/api.py` + +- [ ] **Step 1: Implement `/customers`, `/usage`, `/plans` (delegating to handlers)** + +Replace the `post_usage` stub and add the customer/plans routes. Each authenticates, parses JSON, calls the model handler, returns its dict: +```python + def _read_json(self): + try: + raw = request.httprequest.get_data(as_text=True) or "{}" + return json.loads(raw) + except Exception: + return None + + @http.route(f"{API_BASE}/customers", type="http", auth="none", methods=["POST"], csrf=False) + def post_customer(self, **kw): + service = self._authenticate() + if not service: + return self._json({"error": "unauthorized"}, status=401) + payload = self._read_json() + if payload is None: + return self._json({"error": "invalid json"}, status=400) + return self._json(service._api_upsert_customer(payload)) + + @http.route(f"{API_BASE}/usage", type="http", auth="none", methods=["POST"], csrf=False) + def post_usage(self, **kw): + service = self._authenticate() + if not service: + return self._json({"error": "unauthorized"}, status=401) + payload = self._read_json() + if payload is None: + return self._json({"error": "invalid json"}, status=400) + return self._json(service._api_record_usage(payload), status=202) + + @http.route(f"{API_BASE}/plans", type="http", auth="none", methods=["GET"], csrf=False) + def get_plans(self, **kw): + service = self._authenticate() + if not service: + return self._json({"error": "unauthorized"}, status=401) + return self._json(service._api_catalog()) + + @http.route(f"{API_BASE}/subscriptions", type="http", auth="none", methods=["POST"], csrf=False) + def post_subscription(self, **kw): + service = self._authenticate() + if not service: + return self._json({"error": "unauthorized"}, status=401) + payload = self._read_json() + if payload is None: + return self._json({"error": "invalid json"}, status=400) + return self._json(service._api_create_subscription(payload)) +``` + +Also refactor the scaffold's `_authenticate` to reuse the Task 1 helper (DRY — drop the duplicated hash/search): +```python + def _authenticate(self): + auth = request.httprequest.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return None + return request.env["fusion.billing.service"].sudo()._match_api_key(auth[7:].strip()) or None +``` + +Add `import json` at the top of `controllers/api.py` (alongside `hashlib`). + +- [ ] **Step 2: Verify module upgrades cleanly** + +Run: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_centralize_billing --stop-after-init` +Expected: ends with no traceback; `Modules loaded.` + +- [ ] **Step 3: Smoke-test the live endpoint** (optional, dev only) + +```bash +# In odoo-shell, generate a key for a service, then: +curl -s -X POST http://localhost:8069/api/billing/v1/customers \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '{"external_id":"smoke-1","name":"Smoke Test"}' +# Expect: {"status":"ok","partner_id":,"external_id":"smoke-1"} +``` + +- [ ] **Step 4: Commit** + +```bash +git add fusion_centralize_billing/controllers/api.py +git commit -m "feat(billing): wire HTTP controllers to API handlers" +``` + +--- + +## Task 10: Usage-rating cron (rate open periods → invoice line) + +**Read reference first:** +```bash +docker exec odoo-nexa-app bash -lc "grep -nE 'def _create_recurring_invoice|def _create_invoices|def _get_invoiceable_lines' /mnt/enterprise-addons/sale_subscription/models/sale_order.py | head" +``` +Confirm how a subscription's upcoming invoice is generated, and whether to (a) add a `sale.order.line` for the overage product, or (b) post an `account.move` line. The core uses approach (a): add/update an overage line on the subscription so native invoicing bills it. + +**Files:** +- Modify: `fusion_centralize_billing/models/usage.py` +- Modify: `fusion_centralize_billing/tests/test_usage.py` + +- [ ] **Step 1: Write the failing test** (append to `test_usage.py`) + +```python + def test_rate_open_period_creates_overage_line(self): + product = self.env['product.product'].sudo().create( + {'name': 'API overage', 'type': 'service', 'list_price': 0.0}) + charge = self.env['fusion.billing.charge'].sudo().create({ + 'name': 'overage', 'plan_code': 'p', 'metric_id': self.metric.id, + 'product_id': product.id, 'included_quota': 100.0, + 'price_per_unit': 0.10, 'unit_batch': 1000.0, 'charge_model': 'standard'}) + self.Usage._record_usage(self.sub, 'cpu_seconds', 1100.0, + '2026-05-01', '2026-06-01', idem='r1') + amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01') + # 1100 - 100 = 1000 overage = 1 batch * $0.10 = $0.10 + self.assertAlmostEqual(amount, 0.10, places=2) + line = self.sub.order_line.filtered(lambda l: l.product_id == product) + self.assertTrue(line) +``` + +- [ ] **Step 2: Run it, expect failure** + +Expected: FAIL — `_fc_rate_usage` not defined on `sale.order`. + +- [ ] **Step 3: Implement the rating method on `sale.order`** + +Create `fusion_centralize_billing/models/sale_order.py`: +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +from odoo import api, fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + def _fc_rate_usage(self, charge, period_start, period_end): + """Aggregate this subscription's usage for `charge`'s metric in the period, + compute the overage amount, and upsert a matching overage order line. + Returns the amount.""" + self.ensure_one() + Usage = self.env['fusion.billing.usage'] + total = Usage._aggregate(self, charge.metric_id, period_start, period_end) + _overage, amount = charge._compute_billable(total) + if charge.product_id: + line = self.order_line.filtered(lambda l: l.product_id == charge.product_id) + vals = {'product_uom_qty': 1, 'price_unit': amount} + if line: + line.write(vals) + else: + self.env['sale.order.line'].create( + {'order_id': self.id, 'product_id': charge.product_id.id, **vals}) + return amount +``` +Register it in `models/__init__.py` (add `from . import sale_order`). + +- [ ] **Step 4: Run it, expect pass** + +Expected: PASS. (If `sale.order.line.create` requires a pricelist/tax setup the fresh DB lacks, set them in setUp — don't weaken the assertion.) + +- [ ] **Step 5: Add the rating cron** + +Create `fusion_centralize_billing/data/ir_cron.xml`: +```xml + + + + Fusion Billing: Rate usage before invoicing + + code + model._cron_rate_open_periods() + 1 + hours + True + + + + Fusion Billing: Dispatch outbound webhooks + + code + model._cron_dispatch() + 2 + minutes + True + + +``` +Add a minimal driver `_cron_rate_open_periods` to `models/usage.py` (iterates active subscriptions with charges for the current period and calls `_fc_rate_usage`): +```python + @api.model + def _cron_rate_open_periods(self): + Charge = self.env['fusion.billing.charge'].search([('active', '=', True)]) + SaleOrder = self.env['sale.order'] + for charge in Charge: + subs = SaleOrder.search([ + ('is_subscription', '=', True), + ('subscription_state', '=', '3_progress'), + ('plan_id.name', '!=', False), + ]) + for sub in subs: + if not sub.next_invoice_date: + continue + period_end = fields.Datetime.to_datetime(sub.next_invoice_date) + period_start = period_end.replace(day=1) + sub._fc_rate_usage(charge, period_start, period_end) +``` +Register `data/ir_cron.xml` in `__manifest__.py` `data` list (after the security line). + +- [ ] **Step 6: Run module upgrade + tests** + +Run: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_centralize_billing --test-enable --test-tags /fusion_centralize_billing --stop-after-init` +Expected: crons load; all tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add fusion_centralize_billing/models/sale_order.py fusion_centralize_billing/models/__init__.py fusion_centralize_billing/models/usage.py fusion_centralize_billing/data/ir_cron.xml fusion_centralize_billing/__manifest__.py fusion_centralize_billing/tests/test_usage.py +git commit -m "feat(billing): usage-rating + webhook-dispatch crons" +``` + +--- + +## Task 11: Full module test run + lint pass + +**Files:** none (verification task) + +- [ ] **Step 1: Full test run** + +Run: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_centralize_billing --test-enable --test-tags /fusion_centralize_billing --stop-after-init 2>&1 | tail -30` +Expected: `0 failed, 0 error(s)`. Fix any failure at its source (do not weaken assertions). + +- [ ] **Step 2: Confirm no `_sql_constraints` regressions** + +Run: `grep -rn "_sql_constraints" fusion_centralize_billing/ || echo "clean"` +Expected: `clean` (all constraints are `models.Constraint`). + +- [ ] **Step 3: Confirm no `sale.subscription` model references** + +Run: `grep -rn "sale\.subscription[^.]" fusion_centralize_billing/ || echo "clean"` +Expected: `clean` (only `sale.subscription.plan` is valid; bare `sale.subscription` is not a model). + +- [ ] **Step 4: Commit (if any fixes)** + +```bash +git add -A fusion_centralize_billing/ +git commit -m "test(billing): full core engine test pass" +``` + +--- + +## Done = core engine complete + +When all tasks pass: a source app can authenticate with a bearer key, upsert a unified customer, create a subscription `sale.order`, push idempotent usage counters, have them rated against quota+overage onto an invoice line, and receive HMAC-signed lifecycle webhooks with retry. Native Odoo handles invoicing/tax/Stripe. + +## Next plan (not this one): NexaCloud adapter + dual-run + +- Map `nexacloud` DB (users/products/plans/deployments) → partners/links/subscriptions/charges (importer script). +- CPU-seconds metric + throttle-removal one-off invoice; `usage_metering.py` → `POST /usage`. +- `invoice.payment_failed`/`subscription.terminated` webhooks → NexaCloud suspend/deprovision. +- `fusion.billing.reconciliation`: shadow-mode Odoo-vs-NexaCloud per period; flip when within tolerance. +- Invoice list/get/PDF/void/retry + credit-note endpoints (deferred from core; add when the adapter needs them). From 032b10752eaf620945fa69c00214fa01eadc2c10 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 02:37:20 -0400 Subject: [PATCH 03/37] test(billing): odoo-trial Enterprise test runner + plan test-env fix Local dev Odoo is Community (can't install the module). Add a guest-exec runner that syncs the module to the odoo-trial Enterprise sandbox (VM 316, db trial) and runs --test-enable there; pass = FCB_EXIT=0. Scaffold verified installing on Odoo 19.0 Enterprise (7 fusion_billing_* tables created). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...26-05-27-fusion-centralize-billing-core.md | 16 ++++++----- scripts/fcb_test_on_trial.sh | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 scripts/fcb_test_on_trial.sh diff --git a/docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md b/docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md index b73f3115..0763714f 100644 --- a/docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md +++ b/docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md @@ -25,17 +25,19 @@ ## Test environment -The module depends on Enterprise modules, so tests run on an instance that has them. Use the dev container (it bind-mounts `Odoo-Modules` at `/mnt/odoo-modules`): +**Verified 2026-05-27.** The local dev Odoo (`odoo-modsdev`) is **Community** and CANNOT install this module (no `sale_subscription`/`account_accountant`). Tests run on the **odoo-trial** Enterprise sandbox (Proxmox VM 316, Odoo 19.0 Enterprise, db `trial`), reached via Proxmox guest-exec (VM 316 has no direct SSH — only `qm guest exec` through `pve-worker1`). A committed runner handles sync + test: ```bash -docker exec odoo-modsdev-app odoo -d fusion-dev \ - -i fusion_centralize_billing --test-enable \ - --test-tags /fusion_centralize_billing --stop-after-init +bash scripts/fcb_test_on_trial.sh ``` -- First run uses `-i` (install); later runs use `-u` (upgrade). -- **Never** run `--test-enable -u` against production `nexamain`. If the dev instance lacks Enterprise addons, provision a throwaway test DB on `odoo-nexa` and run there. -- A task "passes" when the run ends with `0 failed, 0 error(s)` for the module's tags. +It tars the module, ships it into odoo-trial's `/opt/odoo/custom-addons/`, then runs +`odoo -d trial -u fusion_centralize_billing --no-http --workers 0 --test-enable --test-tags /fusion_centralize_billing --stop-after-init`. + +- **Pass condition:** the output contains `FCB_EXIT=0` (Odoo exits non-zero on any test failure; failures also show as `FAIL`/`ERROR`/assert lines). +- The scaffold is already installed on `trial` (7 `fusion_billing_*` tables verified). Each run re-syncs the latest local code and `-u` upgrades it. +- **Never** run `--test-enable` against production `nexamain` (odoo-nexa). +- Requires SSH access to `pve-worker1` (in the ssh config). Subagents run the same script — they do NOT use `odoo-modsdev` for this module. ## File structure (this plan) diff --git a/scripts/fcb_test_on_trial.sh b/scripts/fcb_test_on_trial.sh new file mode 100644 index 00000000..c8006357 --- /dev/null +++ b/scripts/fcb_test_on_trial.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Sync fusion_centralize_billing to the odoo-trial Enterprise sandbox (Proxmox VM 316) +# and run its test suite there. The local dev Odoo (odoo-modsdev) is Community and +# CANNOT install this module (needs sale_subscription + account_accountant), so tests +# run on odoo-trial (Odoo 19.0 Enterprise, db=trial), reached via Proxmox guest-exec +# (VM 316 has no direct SSH; only `qm guest exec` through the pve-worker1 host). +# +# Usage: bash scripts/fcb_test_on_trial.sh +# Pass condition: the output ends with `FCB_EXIT=0` (Odoo exits non-zero on test failure). +set -uo pipefail + +MODULE=fusion_centralize_billing +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PVE=pve-worker1 # Proxmox host that runs VM 316 (ssh config alias) +VMID=316 + +echo ">> packing ${MODULE}" +B64=$(tar czf - --exclude='__pycache__' --exclude='*.pyc' -C "${REPO_DIR}" "${MODULE}" | base64 -w0) +echo " payload: ${#B64} b64 bytes" + +echo ">> syncing to odoo-trial:/opt/odoo/custom-addons (guest-exec)" +ssh -o ConnectTimeout=40 "${PVE}" "qm guest exec ${VMID} --timeout 90 -- bash -lc 'rm -rf /opt/odoo/custom-addons/${MODULE}; echo ${B64} | base64 -d | tar xzf - -C /opt/odoo/custom-addons/ && echo SYNCED'" \ + 2>&1 | sed -n 's/.*"out-data" : "\(.*\)",/\1/p' | sed 's/\\n/\n/g' + +echo ">> upgrade + test on Enterprise 19 (db=trial, --no-http)" +ssh -o ConnectTimeout=40 "${PVE}" "qm guest exec ${VMID} --timeout 600 -- bash -lc 'docker exec odoo-trial-app odoo -d trial -u ${MODULE} --no-http --workers 0 --test-enable --test-tags /${MODULE} --stop-after-init >/tmp/fcb_test.log 2>&1; echo FCB_EXIT=\$?; grep -iE \"FAIL|ERROR|tested in|Ran |assert\" /tmp/fcb_test.log | grep -viE \"fusion_plating|fusion_tasks|not installable|not loaded\" | tail -30'" \ + 2>&1 | sed -n 's/.*"out-data" : "\(.*\)",/\1/p' | sed 's/\\n/\n/g' From a46e31e7103a9a31df6efecb3c1ee79c6e428cb6 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 02:44:30 -0400 Subject: [PATCH 04/37] feat(billing): service API-key generation + matching Add _match_api_key() class method to fusion.billing.service, with a TDD test suite (TestServiceApiKey) covering key generation, hash storage, positive match, and rejection of bad/inactive keys. Also fix fcb_test_on_trial.sh to use --http-port 8070, as Odoo 19 forces http_spawn() even under --no-http when --test-enable is set. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 11 ++++++-- fusion_centralize_billing/models/service.py | 8 ++++++ fusion_centralize_billing/tests/__init__.py | 1 + .../tests/test_identity.py | 25 +++++++++++++++++++ scripts/fcb_test_on_trial.sh | 2 +- 5 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 fusion_centralize_billing/tests/__init__.py create mode 100644 fusion_centralize_billing/tests/test_identity.py diff --git a/CLAUDE.md b/CLAUDE.md index 23784c83..29f7c1c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,8 +81,15 @@ Odoo content-hashes the compiled bundle URL (`/web/assets//...`). When CSS - **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state ## Workflow -- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u --stop-after-init` -- Local URL: http://localhost:8069 +- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u --stop-after-init` +- Local URL: http://localhost:8082 +- **Running module tests requires ephemeral ports.** The dev container's main Odoo process holds 8069 and 8072; a `docker exec ... odoo --test-enable` will die with `Address already in use` unless you also pass `--http-port=0 --gevent-port=0`. This is because Odoo 19 forces `http_spawn()` when `--test-enable` is set, even when `--no-http` is passed. Canonical test invocation: + ```bash + docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags / \ + -u --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60 + ``` +- **`fusion_centralize_billing` tests run on odoo-trial (VM 316).** Local dev is Community and cannot install this module. Use `bash scripts/fcb_test_on_trial.sh` from the repo root. The script uses `--http-port 8070` to avoid the port 8069 conflict with the live odoo-trial-app container. Pass = `FCB_EXIT=0`. Takes ~1-2 min. +- **Python deps not bundled with `odoo:19` image:** `user_agents` (used by `fusion_login_audit`), and likely others. Install ephemerally with `docker exec -u 0 odoo-modsdev-app pip install --break-system-packages`. The install is LOST when the container is recreated (e.g. `docker compose up -d` after a compose edit). When this happens, the symptom is `ModuleNotFoundError` deep in the auth or report code. Re-run the pip install. A persistent fix would be a custom Dockerfile or a startup hook on the compose service — not done yet. - Test before deploying. Edit existing files — don't create unnecessary new ones. ## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab diff --git a/fusion_centralize_billing/models/service.py b/fusion_centralize_billing/models/service.py index 953eac7a..5dc83a1e 100644 --- a/fusion_centralize_billing/models/service.py +++ b/fusion_centralize_billing/models/service.py @@ -54,3 +54,11 @@ class FusionBillingService(models.Model): raw = secrets.token_urlsafe(32) self.api_key_hash = hashlib.sha256(raw.encode()).hexdigest() return raw + + @api.model + def _match_api_key(self, raw_key): + """Return the active service whose stored hash matches raw_key, else empty recordset.""" + if not raw_key: + return self.browse() + key_hash = hashlib.sha256(raw_key.encode()).hexdigest() + return self.search([('api_key_hash', '=', key_hash), ('active', '=', True)], limit=1) diff --git a/fusion_centralize_billing/tests/__init__.py b/fusion_centralize_billing/tests/__init__.py new file mode 100644 index 00000000..7399d626 --- /dev/null +++ b/fusion_centralize_billing/tests/__init__.py @@ -0,0 +1 @@ +from . import test_identity diff --git a/fusion_centralize_billing/tests/test_identity.py b/fusion_centralize_billing/tests/test_identity.py new file mode 100644 index 00000000..7b24ec12 --- /dev/null +++ b/fusion_centralize_billing/tests/test_identity.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestServiceApiKey(TransactionCase): + + def setUp(self): + super().setUp() + self.Service = self.env['fusion.billing.service'].sudo() + self.service = self.Service.create({'name': 'NexaCloud', 'code': 'nexacloud'}) + + def test_generate_and_match_api_key(self): + raw = self.service.action_generate_api_key() + self.assertTrue(raw and len(raw) >= 20) + self.assertTrue(self.service.api_key_hash) + self.assertNotEqual(raw, self.service.api_key_hash) # only the hash is stored + matched = self.Service._match_api_key(raw) + self.assertEqual(matched, self.service) + + def test_match_api_key_rejects_unknown_and_inactive(self): + raw = self.service.action_generate_api_key() + self.assertFalse(self.Service._match_api_key('nope-not-a-key')) + self.service.active = False + self.assertFalse(self.Service._match_api_key(raw)) diff --git a/scripts/fcb_test_on_trial.sh b/scripts/fcb_test_on_trial.sh index c8006357..a8805503 100644 --- a/scripts/fcb_test_on_trial.sh +++ b/scripts/fcb_test_on_trial.sh @@ -23,5 +23,5 @@ ssh -o ConnectTimeout=40 "${PVE}" "qm guest exec ${VMID} --timeout 90 -- bash -l 2>&1 | sed -n 's/.*"out-data" : "\(.*\)",/\1/p' | sed 's/\\n/\n/g' echo ">> upgrade + test on Enterprise 19 (db=trial, --no-http)" -ssh -o ConnectTimeout=40 "${PVE}" "qm guest exec ${VMID} --timeout 600 -- bash -lc 'docker exec odoo-trial-app odoo -d trial -u ${MODULE} --no-http --workers 0 --test-enable --test-tags /${MODULE} --stop-after-init >/tmp/fcb_test.log 2>&1; echo FCB_EXIT=\$?; grep -iE \"FAIL|ERROR|tested in|Ran |assert\" /tmp/fcb_test.log | grep -viE \"fusion_plating|fusion_tasks|not installable|not loaded\" | tail -30'" \ +ssh -o ConnectTimeout=40 "${PVE}" "qm guest exec ${VMID} --timeout 600 -- bash -lc 'docker exec odoo-trial-app odoo -d trial -u ${MODULE} --no-http --http-port 8070 --workers 0 --test-enable --test-tags /${MODULE} --stop-after-init >/tmp/fcb_test.log 2>&1; echo FCB_EXIT=\$?; grep -iE \"FAIL|ERROR|tested in|Ran |assert\" /tmp/fcb_test.log | grep -viE \"fusion_plating|fusion_tasks|not installable|not loaded\" | tail -30'" \ 2>&1 | sed -n 's/.*"out-data" : "\(.*\)",/\1/p' | sed 's/\\n/\n/g' From a1cfab6fe99b79bca9aafb6ed6d8a7b3b5273265 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 02:47:27 -0400 Subject: [PATCH 05/37] feat(billing): identity resolution external account -> partner --- .../models/account_link.py | 26 +++++++++++++++- .../tests/test_identity.py | 30 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/fusion_centralize_billing/models/account_link.py b/fusion_centralize_billing/models/account_link.py index 66e3c1ed..e8db15a0 100644 --- a/fusion_centralize_billing/models/account_link.py +++ b/fusion_centralize_billing/models/account_link.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 -from odoo import fields, models +from odoo import api, fields, models class FusionBillingAccountLink(models.Model): @@ -31,3 +31,27 @@ class FusionBillingAccountLink(models.Model): "unique(service_id, external_id)", "An external account can only link to one partner per service.", ) + + @api.model + def _resolve_or_create_partner(self, service, external_id, name=None, email=None, extra=None): + """Return the link for (service, external_id), creating partner+link if needed. + + Unifies customers: if a link for this external_id exists, reuse it; else if a + partner with the same email already exists (possibly from another service), + link to it; else create a new partner. + """ + existing = self.search( + [('service_id', '=', service.id), ('external_id', '=', external_id)], limit=1) + if existing: + return existing + partner = self.env['res.partner'] + if email: + partner = partner.search([('email', '=', email)], limit=1) + if not partner: + partner = partner.create({'name': name or external_id, 'email': email, **(extra or {})}) + return self.create({ + 'service_id': service.id, + 'external_id': external_id, + 'external_email': email, + 'partner_id': partner.id, + }) diff --git a/fusion_centralize_billing/tests/test_identity.py b/fusion_centralize_billing/tests/test_identity.py index 7b24ec12..88c68ec0 100644 --- a/fusion_centralize_billing/tests/test_identity.py +++ b/fusion_centralize_billing/tests/test_identity.py @@ -23,3 +23,33 @@ class TestServiceApiKey(TransactionCase): self.assertFalse(self.Service._match_api_key('nope-not-a-key')) self.service.active = False self.assertFalse(self.Service._match_api_key(raw)) + + +@tagged('post_install', '-at_install') +class TestIdentityResolution(TransactionCase): + + def setUp(self): + super().setUp() + self.service = self.env['fusion.billing.service'].sudo().create( + {'name': 'NexaDesk', 'code': 'nexadesk'}) + self.Link = self.env['fusion.billing.account.link'].sudo() + + def test_creates_partner_first_time(self): + link = self.Link._resolve_or_create_partner( + self.service, external_id='tenant-1', name='Acme Inc', email='ar@acme.test') + self.assertTrue(link.partner_id) + self.assertEqual(link.partner_id.name, 'Acme Inc') + self.assertEqual(link.external_id, 'tenant-1') + + def test_idempotent_same_external_id(self): + a = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme', 'ar@acme.test') + b = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme Renamed', 'ar@acme.test') + self.assertEqual(a, b) # same link row + self.assertEqual(a.partner_id, b.partner_id) # same partner + + def test_reuses_partner_by_email_across_services(self): + other = self.env['fusion.billing.service'].sudo().create({'name': 'Maps', 'code': 'nexamaps'}) + a = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme', 'ar@acme.test') + b = self.Link._resolve_or_create_partner(other, 'client-9', 'Acme', 'ar@acme.test') + self.assertEqual(a.partner_id, b.partner_id) # one unified customer + self.assertNotEqual(a, b) # but distinct link rows From 1e34a673845d2a1dcca915c21fb3a439054d6a21 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 02:50:14 -0400 Subject: [PATCH 06/37] feat(billing): metered charge math (quota + overage) Co-Authored-By: Claude Sonnet 4.6 --- fusion_centralize_billing/models/charge.py | 22 ++++++++- fusion_centralize_billing/tests/__init__.py | 1 + .../tests/test_charge.py | 45 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 fusion_centralize_billing/tests/test_charge.py diff --git a/fusion_centralize_billing/models/charge.py b/fusion_centralize_billing/models/charge.py index a194f38d..6f614800 100644 --- a/fusion_centralize_billing/models/charge.py +++ b/fusion_centralize_billing/models/charge.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 -from odoo import fields, models +import math + +from odoo import api, fields, models class FusionBillingCharge(models.Model): @@ -51,3 +53,21 @@ class FusionBillingCharge(models.Model): "res.currency", default=lambda self: self.env.company.currency_id, ) active = fields.Boolean(default=True) + + def _compute_billable(self, total_quantity): + """Return (overage_units, amount) for total period usage under this charge. + + - overage_units = usage above included_quota (never negative) + - 'standard'/'package'/'volume': priced per `unit_batch` block, partial block rounds up. + (graduated tiers are out of scope for the core; treated as 'standard'.) + """ + self.ensure_one() + overage = max(0.0, (total_quantity or 0.0) - (self.included_quota or 0.0)) + batch = self.unit_batch or 1.0 + if self.charge_model == 'package': + # whole packages over the RAW quantity (quota ignored for package counting) + blocks = math.ceil((total_quantity or 0.0) / batch) if total_quantity else 0 + return overage, round(blocks * (self.price_per_unit or 0.0), 2) + # standard / volume / graduated-fallback: price the overage in (rounded-up) batches + blocks = math.ceil(overage / batch) if overage > 0 else 0 + return overage, round(blocks * (self.price_per_unit or 0.0), 2) diff --git a/fusion_centralize_billing/tests/__init__.py b/fusion_centralize_billing/tests/__init__.py index 7399d626..23d9aa9c 100644 --- a/fusion_centralize_billing/tests/__init__.py +++ b/fusion_centralize_billing/tests/__init__.py @@ -1 +1,2 @@ from . import test_identity +from . import test_charge diff --git a/fusion_centralize_billing/tests/test_charge.py b/fusion_centralize_billing/tests/test_charge.py new file mode 100644 index 00000000..ae33f141 --- /dev/null +++ b/fusion_centralize_billing/tests/test_charge.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestChargeMath(TransactionCase): + + def setUp(self): + super().setUp() + self.metric = self.env['fusion.billing.metric'].sudo().create( + {'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'}) + + def _charge(self, **kw): + vals = { + 'name': 'Maps overage', 'plan_code': 'maps-business', + 'metric_id': self.metric.id, 'charge_model': 'standard', + 'included_quota': 5_000_000.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0, + } + vals.update(kw) + return self.env['fusion.billing.charge'].sudo().create(vals) + + def test_under_quota_is_free(self): + charge = self._charge() + overage_units, amount = charge._compute_billable(4_000_000.0) + self.assertEqual(overage_units, 0.0) + self.assertEqual(amount, 0.0) + + def test_standard_overage_per_1k(self): + charge = self._charge() + # 6,000,000 used - 5,000,000 quota = 1,000,000 overage = 1000 batches * $0.10 + overage_units, amount = charge._compute_billable(6_000_000.0) + self.assertEqual(overage_units, 1_000_000.0) + self.assertAlmostEqual(amount, 100.0, places=2) + + def test_partial_batch_rounds_up(self): + charge = self._charge(included_quota=0.0) + # 1,500 units, batch 1000 -> 2 batches -> $0.20 + _, amount = charge._compute_billable(1_500.0) + self.assertAlmostEqual(amount, 0.20, places=2) + + def test_package_model_charges_whole_packages(self): + charge = self._charge(charge_model='package', included_quota=0.0, unit_batch=1000.0, price_per_unit=2.0) + # 2,001 units -> 3 packages -> $6.00 + _, amount = charge._compute_billable(2_001.0) + self.assertAlmostEqual(amount, 6.0, places=2) From eb1ee85d2419dcbbd91c7161a6bed1a3378c7355 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 02:52:05 -0400 Subject: [PATCH 07/37] feat(billing): idempotent usage ingestion Co-Authored-By: Claude Sonnet 4.6 --- fusion_centralize_billing/models/usage.py | 23 ++++++++++++- fusion_centralize_billing/tests/__init__.py | 1 + fusion_centralize_billing/tests/test_usage.py | 33 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 fusion_centralize_billing/tests/test_usage.py diff --git a/fusion_centralize_billing/models/usage.py b/fusion_centralize_billing/models/usage.py index f8e12658..63dde5eb 100644 --- a/fusion_centralize_billing/models/usage.py +++ b/fusion_centralize_billing/models/usage.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 -from odoo import fields, models +from odoo import api, fields, models class FusionBillingUsage(models.Model): @@ -37,3 +37,24 @@ class FusionBillingUsage(models.Model): _idempotency_uniq = models.Constraint( "unique(idempotency_key)", "Usage idempotency key must be unique.", ) + + @api.model + def _record_usage(self, subscription, metric_code, quantity, period_start, period_end, idem=None): + """Upsert one aggregated usage row. Same idempotency key updates in place (no double-count).""" + metric = self.env['fusion.billing.metric'].search([('code', '=', metric_code)], limit=1) + if not metric: + raise ValueError("Unknown metric code: %s" % metric_code) + vals = { + 'subscription_id': subscription.id, + 'metric_id': metric.id, + 'period_start': period_start, + 'period_end': period_end, + 'quantity': quantity, + 'idempotency_key': idem, + } + if idem: + existing = self.search([('idempotency_key', '=', idem)], limit=1) + if existing: + existing.write({'quantity': quantity}) + return existing + return self.create(vals) diff --git a/fusion_centralize_billing/tests/__init__.py b/fusion_centralize_billing/tests/__init__.py index 23d9aa9c..07117e0d 100644 --- a/fusion_centralize_billing/tests/__init__.py +++ b/fusion_centralize_billing/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_identity from . import test_charge +from . import test_usage diff --git a/fusion_centralize_billing/tests/test_usage.py b/fusion_centralize_billing/tests/test_usage.py new file mode 100644 index 00000000..10475bba --- /dev/null +++ b/fusion_centralize_billing/tests/test_usage.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestUsageIngestion(TransactionCase): + + def setUp(self): + super().setUp() + self.metric = self.env['fusion.billing.metric'].sudo().create( + {'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'}) + self.plan = self.env['sale.subscription.plan'].sudo().create( + {'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'}) + self.partner = self.env['res.partner'].sudo().create({'name': 'Acme'}) + self.sub = self.env['sale.order'].sudo().create({ + 'partner_id': self.partner.id, 'is_subscription': True, 'plan_id': self.plan.id, + }) + self.Usage = self.env['fusion.billing.usage'].sudo() + + def test_record_usage_creates_row(self): + u = self.Usage._record_usage( + self.sub, 'cpu_seconds', 120.0, + '2026-05-01 00:00:00', '2026-06-01 00:00:00', idem='nexacloud:cpu:sub1:2026-05-01') + self.assertEqual(u.quantity, 120.0) + self.assertEqual(u.metric_id, self.metric) + + def test_idempotent_key_updates_not_duplicates(self): + k = 'nexacloud:cpu:sub1:2026-05-01' + self.Usage._record_usage(self.sub, 'cpu_seconds', 100.0, '2026-05-01', '2026-06-01', idem=k) + self.Usage._record_usage(self.sub, 'cpu_seconds', 175.0, '2026-05-01', '2026-06-01', idem=k) + rows = self.Usage.search([('idempotency_key', '=', k)]) + self.assertEqual(len(rows), 1) # no duplicate + self.assertEqual(rows.quantity, 175.0) # last value wins for the same key From 25952cf226ce9d1ea089ec4add9e56baf662fba2 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 02:53:47 -0400 Subject: [PATCH 08/37] feat(billing): period usage aggregation by metric function Co-Authored-By: Claude Sonnet 4.6 --- fusion_centralize_billing/models/usage.py | 24 +++++++++++++++++++ fusion_centralize_billing/tests/test_usage.py | 19 +++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/fusion_centralize_billing/models/usage.py b/fusion_centralize_billing/models/usage.py index 63dde5eb..b3ce930b 100644 --- a/fusion_centralize_billing/models/usage.py +++ b/fusion_centralize_billing/models/usage.py @@ -58,3 +58,27 @@ class FusionBillingUsage(models.Model): existing.write({'quantity': quantity}) return existing return self.create(vals) + + @api.model + def _aggregate(self, subscription, metric, period_start, period_end): + """Aggregate stored usage for a subscription+metric within [period_start, period_end) + using the metric's aggregation function.""" + rows = self.search([ + ('subscription_id', '=', subscription.id), + ('metric_id', '=', metric.id), + ('period_start', '>=', period_start), + ('period_end', '<=', period_end), + ]) + qtys = rows.mapped('quantity') + if not qtys: + return 0.0 + agg = metric.aggregation + if agg == 'sum': + return sum(qtys) + if agg == 'max': + return max(qtys) + if agg == 'last': + return rows.sorted('period_start')[-1].quantity + if agg == 'unique_count': + return float(len(set(qtys))) + return sum(qtys) diff --git a/fusion_centralize_billing/tests/test_usage.py b/fusion_centralize_billing/tests/test_usage.py index 10475bba..de8cf2de 100644 --- a/fusion_centralize_billing/tests/test_usage.py +++ b/fusion_centralize_billing/tests/test_usage.py @@ -31,3 +31,22 @@ class TestUsageIngestion(TransactionCase): rows = self.Usage.search([('idempotency_key', '=', k)]) self.assertEqual(len(rows), 1) # no duplicate self.assertEqual(rows.quantity, 175.0) # last value wins for the same key + + def test_aggregate_sum(self): + for i, q in enumerate([10.0, 20.0, 30.0]): + self.Usage._record_usage(self.sub, 'cpu_seconds', q, + '2026-05-01', '2026-06-01', idem='cpu-%d' % i) + total = self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01') + self.assertEqual(total, 60.0) + + def test_aggregate_max(self): + self.metric.aggregation = 'max' + for i, q in enumerate([10.0, 55.0, 30.0]): + self.Usage._record_usage(self.sub, 'cpu_seconds', q, + '2026-05-01', '2026-06-01', idem='m-%d' % i) + self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 55.0) + + def test_aggregate_excludes_other_periods(self): + self.Usage._record_usage(self.sub, 'cpu_seconds', 99.0, '2026-04-01', '2026-05-01', idem='apr') + self.Usage._record_usage(self.sub, 'cpu_seconds', 5.0, '2026-05-01', '2026-06-01', idem='may') + self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 5.0) From 2435096f32bc1992ca2ecbe451e00b072857b6f0 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 02:56:04 -0400 Subject: [PATCH 09/37] feat(billing): inbound API handlers (customer/usage/catalog) Co-Authored-By: Claude Sonnet 4.6 --- fusion_centralize_billing/models/service.py | 31 ++++++++++++++ fusion_centralize_billing/tests/__init__.py | 1 + fusion_centralize_billing/tests/test_api.py | 47 +++++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 fusion_centralize_billing/tests/test_api.py diff --git a/fusion_centralize_billing/models/service.py b/fusion_centralize_billing/models/service.py index 5dc83a1e..40046759 100644 --- a/fusion_centralize_billing/models/service.py +++ b/fusion_centralize_billing/models/service.py @@ -62,3 +62,34 @@ class FusionBillingService(models.Model): return self.browse() key_hash = hashlib.sha256(raw_key.encode()).hexdigest() return self.search([('api_key_hash', '=', key_hash), ('active', '=', True)], limit=1) + + def _api_upsert_customer(self, payload): + self.ensure_one() + ext = payload.get('external_id') + if not ext: + return {'status': 'error', 'error': 'external_id required'} + link = self.env['fusion.billing.account.link']._resolve_or_create_partner( + self, ext, name=payload.get('name'), email=payload.get('email')) + return {'status': 'ok', 'partner_id': link.partner_id.id, 'external_id': ext} + + def _api_record_usage(self, payload): + self.ensure_one() + events = payload.get('events') or [] + Usage = self.env['fusion.billing.usage'] + accepted = 0 + for ev in events: + sub = self.env['sale.order'].browse(int(ev['subscription_external_id'])) + Usage._record_usage( + sub, ev['metric_code'], float(ev['quantity']), + ev['period_start'], ev['period_end'], idem=ev.get('idempotency_key')) + accepted += 1 + return {'status': 'ok', 'accepted': accepted} + + def _api_catalog(self): + self.ensure_one() + charges = self.env['fusion.billing.charge'].search([('active', '=', True)]) + return {'status': 'ok', 'charges': [{ + 'plan_code': c.plan_code, 'metric': c.metric_id.code, + 'included_quota': c.included_quota, 'price_per_unit': c.price_per_unit, + 'unit_batch': c.unit_batch, 'charge_model': c.charge_model, + } for c in charges]} diff --git a/fusion_centralize_billing/tests/__init__.py b/fusion_centralize_billing/tests/__init__.py index 07117e0d..225b18dc 100644 --- a/fusion_centralize_billing/tests/__init__.py +++ b/fusion_centralize_billing/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_identity from . import test_charge from . import test_usage +from . import test_api diff --git a/fusion_centralize_billing/tests/test_api.py b/fusion_centralize_billing/tests/test_api.py new file mode 100644 index 00000000..5ff65b2d --- /dev/null +++ b/fusion_centralize_billing/tests/test_api.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestApiHandlers(TransactionCase): + + def setUp(self): + super().setUp() + self.service = self.env['fusion.billing.service'].sudo().create( + {'name': 'NexaMaps', 'code': 'nexamaps'}) + self.env['fusion.billing.metric'].sudo().create( + {'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'}) + self.plan = self.env['sale.subscription.plan'].sudo().create( + {'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'}) + + def test_api_upsert_customer(self): + res = self.service._api_upsert_customer( + {'external_id': 'client-9', 'name': 'Globex', 'email': 'billing@globex.test'}) + self.assertEqual(res['status'], 'ok') + link = self.env['fusion.billing.account.link'].search( + [('service_id', '=', self.service.id), ('external_id', '=', 'client-9')]) + self.assertEqual(link.partner_id.name, 'Globex') + + def test_api_record_usage_batch(self): + self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'}) + partner = self.env['fusion.billing.account.link'].search( + [('external_id', '=', 'client-9')]).partner_id + sub = self.env['sale.order'].sudo().create( + {'partner_id': partner.id, 'is_subscription': True, 'plan_id': self.plan.id}) + res = self.service._api_record_usage({'events': [{ + 'subscription_external_id': str(sub.id), 'metric_code': 'api_calls', + 'quantity': 1234.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01', + 'idempotency_key': 'maps:client-9:2026-05-01', + }]}) + self.assertEqual(res['accepted'], 1) + usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)]) + self.assertEqual(usage.quantity, 1234.0) + + def test_api_catalog_lists_active_charges(self): + self.env['fusion.billing.charge'].sudo().create({ + 'name': 'Maps overage', 'plan_code': 'maps-business', + 'metric_id': self.env['fusion.billing.metric'].search([('code', '=', 'api_calls')]).id, + 'included_quota': 5_000_000.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0}) + cat = self.service._api_catalog() + codes = [c['plan_code'] for c in cat['charges']] + self.assertIn('maps-business', codes) From 0754d0b101ae34f532fb2c52cbfec90f63976337 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 02:59:17 -0400 Subject: [PATCH 10/37] feat(billing): subscription creation handler (sale.order is_subscription) Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_centralize_billing/models/service.py | 27 +++++++++++++++++++++ fusion_centralize_billing/tests/test_api.py | 16 ++++++++++++ 2 files changed, 43 insertions(+) diff --git a/fusion_centralize_billing/models/service.py b/fusion_centralize_billing/models/service.py index 40046759..85379032 100644 --- a/fusion_centralize_billing/models/service.py +++ b/fusion_centralize_billing/models/service.py @@ -93,3 +93,30 @@ class FusionBillingService(models.Model): 'included_quota': c.included_quota, 'price_per_unit': c.price_per_unit, 'unit_batch': c.unit_batch, 'charge_model': c.charge_model, } for c in charges]} + + def _api_create_subscription(self, payload): + """Create and confirm a subscription sale.order for an external customer. + + The product on each line must have recurring_invoice=True so that + Odoo recognises the order as a subscription with has_recurring_line and + action_confirm() reaches subscription_state='3_progress'. + """ + self.ensure_one() + link = self.env['fusion.billing.account.link'].search([ + ('service_id', '=', self.id), + ('external_id', '=', payload.get('external_customer_id')), + ], limit=1) + if not link: + return {'status': 'error', 'error': 'unknown customer'} + order_lines = [(0, 0, { + 'product_id': line['product_id'], + 'product_uom_qty': line.get('quantity', 1), + }) for line in payload.get('lines', [])] + sub = self.env['sale.order'].sudo().create({ + 'partner_id': link.partner_id.id, + 'plan_id': payload['plan_id'], + 'order_line': order_lines, + }) + sub.action_confirm() + return {'status': 'ok', 'subscription_id': sub.id, + 'subscription_state': sub.subscription_state} diff --git a/fusion_centralize_billing/tests/test_api.py b/fusion_centralize_billing/tests/test_api.py index 5ff65b2d..c7fbe724 100644 --- a/fusion_centralize_billing/tests/test_api.py +++ b/fusion_centralize_billing/tests/test_api.py @@ -45,3 +45,19 @@ class TestApiHandlers(TransactionCase): cat = self.service._api_catalog() codes = [c['plan_code'] for c in cat['charges']] self.assertIn('maps-business', codes) + + def test_api_create_subscription(self): + self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'}) + product = self.env['product.product'].sudo().create( + {'name': 'Maps Business', 'type': 'service', 'recurring_invoice': True, + 'list_price': 249.0}) + res = self.service._api_create_subscription({ + 'external_customer_id': 'client-9', + 'plan_id': self.plan.id, + 'lines': [{'product_id': product.id, 'quantity': 1}], + }) + self.assertEqual(res['status'], 'ok') + sub = self.env['sale.order'].browse(res['subscription_id']) + self.assertTrue(sub.is_subscription) + self.assertEqual(sub.plan_id, self.plan) + self.assertEqual(sub.subscription_state, '3_progress') From 6c395709cf556ea99fd27140ba251aab7aad9408 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 03:04:05 -0400 Subject: [PATCH 11/37] feat(billing): outbound webhook engine (HMAC + retry/backoff) Co-Authored-By: Claude Sonnet 4.6 --- fusion_centralize_billing/models/webhook.py | 61 ++++++++++++++++++- fusion_centralize_billing/tests/__init__.py | 1 + .../tests/test_webhook.py | 53 ++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 fusion_centralize_billing/tests/test_webhook.py diff --git a/fusion_centralize_billing/models/webhook.py b/fusion_centralize_billing/models/webhook.py index 14b2ea81..acb01711 100644 --- a/fusion_centralize_billing/models/webhook.py +++ b/fusion_centralize_billing/models/webhook.py @@ -1,7 +1,19 @@ # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 -from odoo import fields, models +import hashlib +import hmac +import json +import logging +from datetime import timedelta + +import requests + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + +MAX_ATTEMPTS = 8 class FusionBillingWebhook(models.Model): @@ -40,3 +52,50 @@ class FusionBillingWebhook(models.Model): next_retry_at = fields.Datetime() signature = fields.Char(help="HMAC-SHA256 of the payload using the service webhook_secret.") last_error = fields.Text() + + @api.model + def _sign(self, secret, body): + return hmac.new((secret or '').encode(), body.encode(), hashlib.sha256).hexdigest() + + @api.model + def _enqueue(self, service, event_type, payload): + body = json.dumps(payload, sort_keys=True, separators=(',', ':')) + return self.create({ + 'service_id': service.id, + 'event_type': event_type, + 'payload': payload, + 'signature': self._sign(service.webhook_secret, body), + 'state': 'pending', + 'next_retry_at': fields.Datetime.now(), + }) + + @api.model + def _cron_dispatch(self): + now = fields.Datetime.now() + due = self.search([ + ('state', 'in', ('pending', 'failed')), + ('next_retry_at', '<=', now), + ], limit=100) + for wh in due: + body = json.dumps(wh.payload, sort_keys=True, separators=(',', ':')) + try: + resp = requests.post( + wh.service_id.webhook_url, + data=body, + headers={'Content-Type': 'application/json', + 'X-Fusion-Signature': wh.signature, + 'X-Fusion-Event': wh.event_type}, + timeout=10, + ) + ok = 200 <= resp.status_code < 300 + except Exception as e: # noqa: BLE001 - record and retry + ok = False + wh.last_error = str(e)[:500] + wh.attempts += 1 + if ok: + wh.state = 'sent' + elif wh.attempts >= MAX_ATTEMPTS: + wh.state = 'dead' + else: + wh.state = 'failed' + wh.next_retry_at = now + timedelta(minutes=2 ** wh.attempts) diff --git a/fusion_centralize_billing/tests/__init__.py b/fusion_centralize_billing/tests/__init__.py index 225b18dc..6091734a 100644 --- a/fusion_centralize_billing/tests/__init__.py +++ b/fusion_centralize_billing/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_identity from . import test_charge from . import test_usage from . import test_api +from . import test_webhook diff --git a/fusion_centralize_billing/tests/test_webhook.py b/fusion_centralize_billing/tests/test_webhook.py new file mode 100644 index 00000000..aa8e9731 --- /dev/null +++ b/fusion_centralize_billing/tests/test_webhook.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +import hashlib +import hmac +import json +from unittest.mock import patch + +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestWebhookEngine(TransactionCase): + + def setUp(self): + super().setUp() + self.service = self.env['fusion.billing.service'].sudo().create({ + 'name': 'NexaCloud', 'code': 'nexacloud', + 'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook', + 'webhook_secret': 'whsec_test', + }) + self.Webhook = self.env['fusion.billing.webhook'].sudo() + + def test_enqueue_signs_payload(self): + wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-1'}) + self.assertEqual(wh.state, 'pending') + body = json.dumps({'invoice': 'INV-1'}, sort_keys=True, separators=(',', ':')) + expected = hmac.new(b'whsec_test', body.encode(), hashlib.sha256).hexdigest() + self.assertEqual(wh.signature, expected) + + def test_dispatch_marks_sent_on_2xx(self): + wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-2'}) + + class _Resp: + status_code = 200 + text = 'ok' + + with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post', + return_value=_Resp()) as mock_post: + self.Webhook._cron_dispatch() + self.assertTrue(mock_post.called) + self.assertEqual(wh.state, 'sent') + + def test_dispatch_retries_then_deadletters(self): + wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-3'}) + wh.write({'attempts': 7}) # already past max + + class _Resp: + status_code = 500 + text = 'err' + + with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post', + return_value=_Resp()): + self.Webhook._cron_dispatch() + self.assertEqual(wh.state, 'dead') From c44fd89ed1e846b6429db9ca64c8fc8bc1c470d9 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 03:05:55 -0400 Subject: [PATCH 12/37] feat(billing): wire HTTP controllers to API handlers --- fusion_centralize_billing/controllers/api.py | 70 ++++++++++++-------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/fusion_centralize_billing/controllers/api.py b/fusion_centralize_billing/controllers/api.py index d9001684..5de832ea 100644 --- a/fusion_centralize_billing/controllers/api.py +++ b/fusion_centralize_billing/controllers/api.py @@ -12,7 +12,7 @@ to be implemented from the writing-plans output. Per repo CLAUDE.md, read live O references (sale.order subscription flow, account.move, payment_stripe) before implementing — do NOT code those internals from memory. """ -import hashlib +import json import logging from odoo import http @@ -31,44 +31,56 @@ class FusionBillingApi(http.Controller): auth = request.httprequest.headers.get("Authorization", "") if not auth.startswith("Bearer "): return None - raw = auth[7:].strip() - if not raw: - return None - key_hash = hashlib.sha256(raw.encode()).hexdigest() - service = request.env["fusion.billing.service"].sudo().search( - [("api_key_hash", "=", key_hash), ("active", "=", True)], limit=1, - ) - return service or None + return request.env["fusion.billing.service"].sudo()._match_api_key(auth[7:].strip()) or None def _json(self, payload, status=200): return request.make_json_response(payload, status=status) + def _read_json(self): + try: + raw = request.httprequest.get_data(as_text=True) or "{}" + return json.loads(raw) + except Exception: + return None + # ── routes ─────────────────────────────────────────────────────────── @http.route(f"{API_BASE}/health", type="http", auth="none", methods=["GET"], csrf=False) def health(self, **kw): return self._json({"status": "ok", "service": "fusion_centralize_billing"}) - @http.route(f"{API_BASE}/usage", type="http", auth="none", methods=["POST"], csrf=False) - def post_usage(self, **kw): - """Hot path: batch aggregated usage counters. Returns 202 once implemented.""" + @http.route(f"{API_BASE}/customers", type="http", auth="none", methods=["POST"], csrf=False) + def post_customer(self, **kw): service = self._authenticate() if not service: return self._json({"error": "unauthorized"}, status=401) - # TODO(spec §6): idempotent upsert into fusion.billing.usage by idempotency_key. - return self._json({"error": "not_implemented"}, status=501) + payload = self._read_json() + if payload is None: + return self._json({"error": "invalid json"}, status=400) + return self._json(service._api_upsert_customer(payload)) - # TODO(spec §7): implement the remaining Lago-shaped endpoints, each gated by - # self._authenticate(): - # POST /customers upsert res.partner + account.link - # POST /subscriptions create subscription sale.order - # PUT /subscriptions/ change / upgrade - # DELETE /subscriptions/ cancel - # POST /invoices one-off invoice (token pack, throttle-removal) - # GET /invoices list (filter by external customer) - # GET /invoices/ fetch - # POST /invoices//download PDF - # POST /invoices//retry_payment retry - # POST /invoices//void void - # POST /credit_notes refund (account.move reversal) - # GET /plans catalog/pricing for the app - # POST /customers//checkout_url Stripe payment-method setup + @http.route(f"{API_BASE}/usage", type="http", auth="none", methods=["POST"], csrf=False) + def post_usage(self, **kw): + service = self._authenticate() + if not service: + return self._json({"error": "unauthorized"}, status=401) + payload = self._read_json() + if payload is None: + return self._json({"error": "invalid json"}, status=400) + return self._json(service._api_record_usage(payload), status=202) + + @http.route(f"{API_BASE}/plans", type="http", auth="none", methods=["GET"], csrf=False) + def get_plans(self, **kw): + service = self._authenticate() + if not service: + return self._json({"error": "unauthorized"}, status=401) + return self._json(service._api_catalog()) + + @http.route(f"{API_BASE}/subscriptions", type="http", auth="none", methods=["POST"], csrf=False) + def post_subscription(self, **kw): + service = self._authenticate() + if not service: + return self._json({"error": "unauthorized"}, status=401) + payload = self._read_json() + if payload is None: + return self._json({"error": "invalid json"}, status=400) + return self._json(service._api_create_subscription(payload)) From a5db0fe71e53fe403525db73b570a3807c54ce90 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 03:08:45 -0400 Subject: [PATCH 13/37] feat(billing): usage-rating + webhook-dispatch crons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SaleOrder._fc_rate_usage: aggregates usage, computes overage via charge._compute_billable, upserts sale.order.line for the overage product - FusionBillingUsage._cron_rate_open_periods: hourly cron iterates active charges × in-progress subscriptions, calls _fc_rate_usage - data/ir_cron.xml: two crons — rate usage (hourly), dispatch webhooks (2 min) - __manifest__.py: registers data/ir_cron.xml in data list - test_usage.py: test_rate_open_period_creates_overage_line (TDD, FCB_EXIT=0) Reference: _create_recurring_invoice / _get_invoiceable_lines confirmed in Enterprise sale_subscription/models/sale_order.py — overage line goes onto sale.order so native invoicing picks it up via _get_invoiceable_lines. --- fusion_centralize_billing/__manifest__.py | 1 + fusion_centralize_billing/data/ir_cron.xml | 22 ++++++++++++++++ fusion_centralize_billing/models/__init__.py | 1 + .../models/sale_order.py | 26 +++++++++++++++++++ fusion_centralize_billing/models/usage.py | 19 ++++++++++++++ fusion_centralize_billing/tests/test_usage.py | 15 +++++++++++ 6 files changed, 84 insertions(+) create mode 100644 fusion_centralize_billing/data/ir_cron.xml create mode 100644 fusion_centralize_billing/models/sale_order.py diff --git a/fusion_centralize_billing/__manifest__.py b/fusion_centralize_billing/__manifest__.py index 6662ea80..a55c6d54 100644 --- a/fusion_centralize_billing/__manifest__.py +++ b/fusion_centralize_billing/__manifest__.py @@ -47,6 +47,7 @@ reference files from the container before implementing subscription/account inte ], "data": [ "security/ir.model.access.csv", + "data/ir_cron.xml", ], "installable": True, "application": False, diff --git a/fusion_centralize_billing/data/ir_cron.xml b/fusion_centralize_billing/data/ir_cron.xml new file mode 100644 index 00000000..97511c84 --- /dev/null +++ b/fusion_centralize_billing/data/ir_cron.xml @@ -0,0 +1,22 @@ + + + + Fusion Billing: Rate usage before invoicing + + code + model._cron_rate_open_periods() + 1 + hours + True + + + + Fusion Billing: Dispatch outbound webhooks + + code + model._cron_dispatch() + 2 + minutes + True + + diff --git a/fusion_centralize_billing/models/__init__.py b/fusion_centralize_billing/models/__init__.py index de3d5dfd..15c4adda 100644 --- a/fusion_centralize_billing/models/__init__.py +++ b/fusion_centralize_billing/models/__init__.py @@ -5,3 +5,4 @@ from . import charge from . import usage from . import webhook from . import reconciliation +from . import sale_order diff --git a/fusion_centralize_billing/models/sale_order.py b/fusion_centralize_billing/models/sale_order.py new file mode 100644 index 00000000..339b4652 --- /dev/null +++ b/fusion_centralize_billing/models/sale_order.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 +from odoo import api, fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + def _fc_rate_usage(self, charge, period_start, period_end): + """Aggregate this subscription's usage for `charge`'s metric in the period, + compute the overage amount, and upsert a matching overage order line. + Returns the amount.""" + self.ensure_one() + Usage = self.env['fusion.billing.usage'] + total = Usage._aggregate(self, charge.metric_id, period_start, period_end) + _overage, amount = charge._compute_billable(total) + if charge.product_id: + line = self.order_line.filtered(lambda l: l.product_id == charge.product_id) + vals = {'product_uom_qty': 1, 'price_unit': amount} + if line: + line.write(vals) + else: + self.env['sale.order.line'].create( + {'order_id': self.id, 'product_id': charge.product_id.id, **vals}) + return amount diff --git a/fusion_centralize_billing/models/usage.py b/fusion_centralize_billing/models/usage.py index b3ce930b..cdb2deef 100644 --- a/fusion_centralize_billing/models/usage.py +++ b/fusion_centralize_billing/models/usage.py @@ -59,6 +59,25 @@ class FusionBillingUsage(models.Model): return existing return self.create(vals) + @api.model + def _cron_rate_open_periods(self): + """Hourly cron: for every active charge, aggregate usage and upsert overage lines + on all in-progress subscriptions whose next invoice date is set.""" + Charge = self.env['fusion.billing.charge'].search([('active', '=', True)]) + SaleOrder = self.env['sale.order'] + for charge in Charge: + subs = SaleOrder.search([ + ('is_subscription', '=', True), + ('subscription_state', '=', '3_progress'), + ('plan_id.name', '!=', False), + ]) + for sub in subs: + if not sub.next_invoice_date: + continue + period_end = fields.Datetime.to_datetime(sub.next_invoice_date) + period_start = period_end.replace(day=1) + sub._fc_rate_usage(charge, period_start, period_end) + @api.model def _aggregate(self, subscription, metric, period_start, period_end): """Aggregate stored usage for a subscription+metric within [period_start, period_end) diff --git a/fusion_centralize_billing/tests/test_usage.py b/fusion_centralize_billing/tests/test_usage.py index de8cf2de..db4ecc1b 100644 --- a/fusion_centralize_billing/tests/test_usage.py +++ b/fusion_centralize_billing/tests/test_usage.py @@ -50,3 +50,18 @@ class TestUsageIngestion(TransactionCase): self.Usage._record_usage(self.sub, 'cpu_seconds', 99.0, '2026-04-01', '2026-05-01', idem='apr') self.Usage._record_usage(self.sub, 'cpu_seconds', 5.0, '2026-05-01', '2026-06-01', idem='may') self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 5.0) + + def test_rate_open_period_creates_overage_line(self): + product = self.env['product.product'].sudo().create( + {'name': 'API overage', 'type': 'service', 'list_price': 0.0}) + charge = self.env['fusion.billing.charge'].sudo().create({ + 'name': 'overage', 'plan_code': 'p', 'metric_id': self.metric.id, + 'product_id': product.id, 'included_quota': 100.0, + 'price_per_unit': 0.10, 'unit_batch': 1000.0, 'charge_model': 'standard'}) + self.Usage._record_usage(self.sub, 'cpu_seconds', 1100.0, + '2026-05-01', '2026-06-01', idem='r1') + amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01') + # 1100 - 100 = 1000 overage = 1 batch * $0.10 = $0.10 + self.assertAlmostEqual(amount, 0.10, places=2) + line = self.sub.order_line.filtered(lambda l: l.product_id == product) + self.assertTrue(line) From d770c0c3a9d2880662a31362645f84adebe8c49d Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 03:27:34 -0400 Subject: [PATCH 14/37] fix(billing): resolve code-review findings (authz, cross-billing, validation, webhook integrity) - C1/H4: rating cron only rates subs on the charge's own plan_id - C1: _fc_rate_usage skips creating a line when amount is 0 (still updates existing) - C2/C4: /usage authorizes each event (exists + is_subscription + linked customer) - C3: API handlers validate input and return 4xx-shaped errors instead of raising; controller maps status=='error' to HTTP 400 - H1: cron uses real billing window [last_invoice_date or start_date, next_invoice_date) - H2: _aggregate uses half-open window anchored on period_start - H3: idempotency scoped to (subscription_id, metric_id, idempotency_key) - H5: webhook stores canonical body, signs+POSTs it verbatim, adds X-Fusion-Event-Id, caps backoff at 2**min(attempts,10) - H6: SSRF guard rejects non-https / localhost / private / link-local webhook_url - M7: charge_model reduced to standard/package (dropped unimplemented graduated/volume) - L1: currency_id required on charge + reconciliation - L2: charge price non-negative + unit_batch positive DB constraints Adds 17 regression tests (suite 22 -> 39, all green via fcb_test_on_trial.sh). Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_centralize_billing/controllers/api.py | 15 ++- fusion_centralize_billing/models/charge.py | 19 ++- .../models/reconciliation.py | 3 +- .../models/sale_order.py | 8 +- fusion_centralize_billing/models/service.py | 123 ++++++++++++++++-- fusion_centralize_billing/models/usage.py | 33 +++-- fusion_centralize_billing/models/webhook.py | 18 ++- fusion_centralize_billing/tests/test_api.py | 76 +++++++++++ .../tests/test_charge.py | 29 +++++ fusion_centralize_billing/tests/test_usage.py | 104 +++++++++++++++ .../tests/test_webhook.py | 46 +++++++ 11 files changed, 442 insertions(+), 32 deletions(-) diff --git a/fusion_centralize_billing/controllers/api.py b/fusion_centralize_billing/controllers/api.py index 5de832ea..37855cf0 100644 --- a/fusion_centralize_billing/controllers/api.py +++ b/fusion_centralize_billing/controllers/api.py @@ -56,7 +56,10 @@ class FusionBillingApi(http.Controller): payload = self._read_json() if payload is None: return self._json({"error": "invalid json"}, status=400) - return self._json(service._api_upsert_customer(payload)) + result = service._api_upsert_customer(payload) + if result.get("status") == "error": + return self._json(result, status=400) + return self._json(result) @http.route(f"{API_BASE}/usage", type="http", auth="none", methods=["POST"], csrf=False) def post_usage(self, **kw): @@ -66,7 +69,10 @@ class FusionBillingApi(http.Controller): payload = self._read_json() if payload is None: return self._json({"error": "invalid json"}, status=400) - return self._json(service._api_record_usage(payload), status=202) + result = service._api_record_usage(payload) + if result.get("status") == "error": + return self._json(result, status=400) + return self._json(result, status=202) @http.route(f"{API_BASE}/plans", type="http", auth="none", methods=["GET"], csrf=False) def get_plans(self, **kw): @@ -83,4 +89,7 @@ class FusionBillingApi(http.Controller): payload = self._read_json() if payload is None: return self._json({"error": "invalid json"}, status=400) - return self._json(service._api_create_subscription(payload)) + result = service._api_create_subscription(payload) + if result.get("status") == "error": + return self._json(result, status=400) + return self._json(result) diff --git a/fusion_centralize_billing/models/charge.py b/fusion_centralize_billing/models/charge.py index 6f614800..bc40f960 100644 --- a/fusion_centralize_billing/models/charge.py +++ b/fusion_centralize_billing/models/charge.py @@ -43,23 +43,30 @@ class FusionBillingCharge(models.Model): charge_model = fields.Selection( [ ("standard", "Standard (per unit)"), - ("graduated", "Graduated"), ("package", "Package"), - ("volume", "Volume"), ], default="standard", required=True, ) currency_id = fields.Many2one( - "res.currency", default=lambda self: self.env.company.currency_id, + "res.currency", required=True, + default=lambda self: self.env.company.currency_id, ) active = fields.Boolean(default=True) + _price_non_negative = models.Constraint( + "CHECK (price_per_unit >= 0)", "Overage price per unit cannot be negative.", + ) + _unit_batch_positive = models.Constraint( + "CHECK (unit_batch > 0)", "Unit batch must be greater than zero.", + ) + def _compute_billable(self, total_quantity): """Return (overage_units, amount) for total period usage under this charge. - overage_units = usage above included_quota (never negative) - - 'standard'/'package'/'volume': priced per `unit_batch` block, partial block rounds up. - (graduated tiers are out of scope for the core; treated as 'standard'.) + - 'standard': price the overage in (rounded-up) `unit_batch` blocks. + - 'package': price whole packages over the RAW quantity (quota ignored for + package counting); a partial package rounds up. """ self.ensure_one() overage = max(0.0, (total_quantity or 0.0) - (self.included_quota or 0.0)) @@ -68,6 +75,6 @@ class FusionBillingCharge(models.Model): # whole packages over the RAW quantity (quota ignored for package counting) blocks = math.ceil((total_quantity or 0.0) / batch) if total_quantity else 0 return overage, round(blocks * (self.price_per_unit or 0.0), 2) - # standard / volume / graduated-fallback: price the overage in (rounded-up) batches + # standard: price the overage in (rounded-up) batches blocks = math.ceil(overage / batch) if overage > 0 else 0 return overage, round(blocks * (self.price_per_unit or 0.0), 2) diff --git a/fusion_centralize_billing/models/reconciliation.py b/fusion_centralize_billing/models/reconciliation.py index 3805aae1..e51f6b71 100644 --- a/fusion_centralize_billing/models/reconciliation.py +++ b/fusion_centralize_billing/models/reconciliation.py @@ -25,7 +25,8 @@ class FusionBillingReconciliation(models.Model): external_amount = fields.Monetary(string="App-actual Amount") delta = fields.Monetary(help="odoo_amount - external_amount.") currency_id = fields.Many2one( - "res.currency", default=lambda self: self.env.company.currency_id, + "res.currency", required=True, + default=lambda self: self.env.company.currency_id, ) status = fields.Selection( [ diff --git a/fusion_centralize_billing/models/sale_order.py b/fusion_centralize_billing/models/sale_order.py index 339b4652..833c306f 100644 --- a/fusion_centralize_billing/models/sale_order.py +++ b/fusion_centralize_billing/models/sale_order.py @@ -10,13 +10,19 @@ class SaleOrder(models.Model): def _fc_rate_usage(self, charge, period_start, period_end): """Aggregate this subscription's usage for `charge`'s metric in the period, compute the overage amount, and upsert a matching overage order line. - Returns the amount.""" + Returns the amount. + + A zero amount never *creates* a new line (no $0.00 overage clutter); if a + line already exists it is still updated so a dropped-to-zero overage clears. + """ self.ensure_one() Usage = self.env['fusion.billing.usage'] total = Usage._aggregate(self, charge.metric_id, period_start, period_end) _overage, amount = charge._compute_billable(total) if charge.product_id: line = self.order_line.filtered(lambda l: l.product_id == charge.product_id) + if not line and amount == 0: + return amount vals = {'product_uom_qty': 1, 'price_unit': amount} if line: line.write(vals) diff --git a/fusion_centralize_billing/models/service.py b/fusion_centralize_billing/models/service.py index 85379032..29f21970 100644 --- a/fusion_centralize_billing/models/service.py +++ b/fusion_centralize_billing/models/service.py @@ -2,9 +2,12 @@ # Copyright 2026 Nexa Systems Inc. # License OPL-1 import hashlib +import ipaddress import secrets +from urllib.parse import urlparse from odoo import api, fields, models +from odoo.exceptions import ValidationError class FusionBillingService(models.Model): @@ -45,6 +48,33 @@ class FusionBillingService(models.Model): for rec in self: rec.account_link_count = len(rec.account_link_ids) + @api.constrains("webhook_url") + def _check_webhook_url(self): + """Reject SSRF-prone webhook targets: a non-empty URL must be https and must + not point at localhost or a private / link-local / loopback IP literal. Empty + is allowed (no webhook configured).""" + for rec in self: + url = (rec.webhook_url or "").strip() + if not url: + continue + parsed = urlparse(url) + if parsed.scheme != "https": + raise ValidationError("Webhook URL must use https.") + host = parsed.hostname or "" + if not host or host.lower() in ("localhost", "ip6-localhost", "ip6-loopback"): + raise ValidationError( + "Webhook URL must not target localhost or a private address.") + try: + ip = ipaddress.ip_address(host) + except ValueError: + ip = None + if ip is not None and ( + ip.is_private or ip.is_loopback or ip.is_link_local + or ip.is_reserved or ip.is_unspecified or ip.is_multicast + ): + raise ValidationError( + "Webhook URL must not target a private or loopback address.") + def action_generate_api_key(self): """Generate a fresh bearer key, store only its hash, return the raw key. @@ -64,7 +94,14 @@ class FusionBillingService(models.Model): return self.search([('api_key_hash', '=', key_hash), ('active', '=', True)], limit=1) def _api_upsert_customer(self, payload): + """Resolve/create the partner link for an external account. + + Defensive: a non-dict payload or a missing/empty ``external_id`` returns a + 4xx-shaped error instead of raising (C3). + """ self.ensure_one() + if not isinstance(payload, dict): + return {'status': 'error', 'error': 'invalid payload'} ext = payload.get('external_id') if not ext: return {'status': 'error', 'error': 'external_id required'} @@ -73,15 +110,53 @@ class FusionBillingService(models.Model): return {'status': 'ok', 'partner_id': link.partner_id.id, 'external_id': ext} def _api_record_usage(self, payload): + """Ingest a batch of usage events. + + Authorization (C2/C4): each event must target a subscription sale.order that + (a) exists, (b) is actually a subscription, and (c) belongs to a customer THIS + service is linked to. Any failing event is rejected and stops processing for + that event without writing a usage row. + + Validation (C3): a non-dict payload, a non-list ``events``, missing required + keys, or non-numeric ``quantity``/ids return a 4xx-shaped error instead of + raising (no 500s). + """ self.ensure_one() - events = payload.get('events') or [] + if not isinstance(payload, dict): + return {'status': 'error', 'error': 'invalid payload'} + events = payload.get('events') + if events is None: + events = [] + if not isinstance(events, list): + return {'status': 'error', 'error': 'events must be a list'} Usage = self.env['fusion.billing.usage'] + linked_partners = self.account_link_ids.mapped('partner_id') accepted = 0 for ev in events: - sub = self.env['sale.order'].browse(int(ev['subscription_external_id'])) - Usage._record_usage( - sub, ev['metric_code'], float(ev['quantity']), - ev['period_start'], ev['period_end'], idem=ev.get('idempotency_key')) + if not isinstance(ev, dict): + return {'status': 'error', 'error': 'invalid event'} + for key in ('subscription_external_id', 'metric_code', 'quantity', + '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) + if not sub.exists() or not sub.is_subscription \ + or sub.partner_id not in linked_partners: + return {'status': 'error', 'error': 'unknown subscription'} + try: + Usage._record_usage( + sub, ev['metric_code'], quantity, + ev['period_start'], ev['period_end'], idem=ev.get('idempotency_key')) + except ValueError as e: + return {'status': 'error', 'error': str(e)} accepted += 1 return {'status': 'ok', 'accepted': accepted} @@ -100,21 +175,49 @@ class FusionBillingService(models.Model): The product on each line must have recurring_invoice=True so that Odoo recognises the order as a subscription with has_recurring_line and action_confirm() reaches subscription_state='3_progress'. + + Validation (C3): a non-dict payload, a missing/unknown customer, a missing + ``plan_id``, a non-list ``lines``, or a non-numeric product id/quantity + return a 4xx-shaped error instead of raising (no 500s). """ self.ensure_one() + if not isinstance(payload, dict): + return {'status': 'error', 'error': 'invalid payload'} + if not payload.get('external_customer_id'): + return {'status': 'error', 'error': 'external_customer_id required'} + if not payload.get('plan_id'): + return {'status': 'error', 'error': 'plan_id required'} + try: + plan_id = int(payload['plan_id']) + except (TypeError, ValueError): + return {'status': 'error', 'error': 'invalid plan_id'} link = self.env['fusion.billing.account.link'].search([ ('service_id', '=', self.id), ('external_id', '=', payload.get('external_customer_id')), ], limit=1) if not link: return {'status': 'error', 'error': 'unknown customer'} - order_lines = [(0, 0, { - 'product_id': line['product_id'], - 'product_uom_qty': line.get('quantity', 1), - }) for line in payload.get('lines', [])] + lines = payload.get('lines') + if lines is None: + lines = [] + if not isinstance(lines, list): + return {'status': 'error', 'error': 'lines must be a list'} + order_lines = [] + for line in lines: + if not isinstance(line, dict) or line.get('product_id') in (None, ''): + return {'status': 'error', 'error': 'invalid line'} + try: + product_id = int(line['product_id']) + quantity = float(line.get('quantity', 1)) + except (TypeError, ValueError): + return {'status': 'error', 'error': 'invalid line'} + order_lines.append((0, 0, { + 'product_id': product_id, + 'product_uom_qty': quantity, + })) sub = self.env['sale.order'].sudo().create({ 'partner_id': link.partner_id.id, - 'plan_id': payload['plan_id'], + 'plan_id': plan_id, 'order_line': order_lines, }) sub.action_confirm() diff --git a/fusion_centralize_billing/models/usage.py b/fusion_centralize_billing/models/usage.py index cdb2deef..394805be 100644 --- a/fusion_centralize_billing/models/usage.py +++ b/fusion_centralize_billing/models/usage.py @@ -35,12 +35,14 @@ class FusionBillingUsage(models.Model): ) _idempotency_uniq = models.Constraint( - "unique(idempotency_key)", "Usage idempotency key must be unique.", + "unique(subscription_id, metric_id, idempotency_key)", + "Usage idempotency key must be unique per subscription and metric.", ) @api.model def _record_usage(self, subscription, metric_code, quantity, period_start, period_end, idem=None): - """Upsert one aggregated usage row. Same idempotency key updates in place (no double-count).""" + """Upsert one aggregated usage row. Same idempotency key (scoped to the same + subscription + metric) updates in place (no double-count).""" metric = self.env['fusion.billing.metric'].search([('code', '=', metric_code)], limit=1) if not metric: raise ValueError("Unknown metric code: %s" % metric_code) @@ -53,7 +55,11 @@ class FusionBillingUsage(models.Model): 'idempotency_key': idem, } if idem: - existing = self.search([('idempotency_key', '=', idem)], limit=1) + existing = self.search([ + ('subscription_id', '=', subscription.id), + ('metric_id', '=', metric.id), + ('idempotency_key', '=', idem), + ], limit=1) if existing: existing.write({'quantity': quantity}) return existing @@ -62,31 +68,42 @@ class FusionBillingUsage(models.Model): @api.model def _cron_rate_open_periods(self): """Hourly cron: for every active charge, aggregate usage and upsert overage lines - on all in-progress subscriptions whose next invoice date is set.""" + on the in-progress subscriptions that are on the charge's own plan. + + A charge only rates subscriptions whose ``plan_id`` matches the charge's + ``plan_id`` — never every subscription against every charge (C1/H4). The + billing-period window is the subscription's real open period + ``[last_invoice_date or start_date, next_invoice_date)`` (H1).""" Charge = self.env['fusion.billing.charge'].search([('active', '=', True)]) SaleOrder = self.env['sale.order'] for charge in Charge: + if not charge.plan_id: + continue subs = SaleOrder.search([ ('is_subscription', '=', True), ('subscription_state', '=', '3_progress'), - ('plan_id.name', '!=', False), + ('plan_id', '=', charge.plan_id.id), ]) for sub in subs: if not sub.next_invoice_date: continue period_end = fields.Datetime.to_datetime(sub.next_invoice_date) - period_start = period_end.replace(day=1) + period_start = fields.Datetime.to_datetime( + sub.last_invoice_date or sub.start_date) + if not period_start: + continue sub._fc_rate_usage(charge, period_start, period_end) @api.model def _aggregate(self, subscription, metric, period_start, period_end): - """Aggregate stored usage for a subscription+metric within [period_start, period_end) + """Aggregate stored usage for a subscription+metric over the half-open window + ``[period_start, period_end)``, anchored on each rollup's ``period_start``, using the metric's aggregation function.""" rows = self.search([ ('subscription_id', '=', subscription.id), ('metric_id', '=', metric.id), ('period_start', '>=', period_start), - ('period_end', '<=', period_end), + ('period_start', '<', period_end), ]) qtys = rows.mapped('quantity') if not qtys: diff --git a/fusion_centralize_billing/models/webhook.py b/fusion_centralize_billing/models/webhook.py index acb01711..d1774fb5 100644 --- a/fusion_centralize_billing/models/webhook.py +++ b/fusion_centralize_billing/models/webhook.py @@ -39,6 +39,10 @@ class FusionBillingWebhook(models.Model): "subscription.terminated / subscription.reactivated / usage.threshold_reached", ) payload = fields.Json() + body = fields.Text( + help="Canonical JSON body that was signed and is POSTed verbatim " + "(so the signature always matches the bytes on the wire).", + ) state = fields.Selection( [ ("pending", "Pending"), @@ -59,11 +63,14 @@ class FusionBillingWebhook(models.Model): @api.model def _enqueue(self, service, event_type, payload): + # Serialize the canonical body ONCE, store it, and sign that exact string so + # the dispatched bytes always match the signature (no re-serialization drift). body = json.dumps(payload, sort_keys=True, separators=(',', ':')) return self.create({ 'service_id': service.id, 'event_type': event_type, 'payload': payload, + 'body': body, 'signature': self._sign(service.webhook_secret, body), 'state': 'pending', 'next_retry_at': fields.Datetime.now(), @@ -77,14 +84,18 @@ class FusionBillingWebhook(models.Model): ('next_retry_at', '<=', now), ], limit=100) for wh in due: - body = json.dumps(wh.payload, sort_keys=True, separators=(',', ':')) + # POST the exact bytes that were signed at enqueue time. Fall back to + # re-serializing the payload only for legacy rows enqueued before `body` + # existed (the signature was computed over the same canonical form). + body = wh.body or json.dumps(wh.payload, sort_keys=True, separators=(',', ':')) try: resp = requests.post( wh.service_id.webhook_url, data=body, headers={'Content-Type': 'application/json', 'X-Fusion-Signature': wh.signature, - 'X-Fusion-Event': wh.event_type}, + 'X-Fusion-Event': wh.event_type, + 'X-Fusion-Event-Id': str(wh.id)}, timeout=10, ) ok = 200 <= resp.status_code < 300 @@ -98,4 +109,5 @@ class FusionBillingWebhook(models.Model): wh.state = 'dead' else: wh.state = 'failed' - wh.next_retry_at = now + timedelta(minutes=2 ** wh.attempts) + # Cap the exponential backoff so the interval can't overflow. + wh.next_retry_at = now + timedelta(minutes=2 ** min(wh.attempts, 10)) diff --git a/fusion_centralize_billing/tests/test_api.py b/fusion_centralize_billing/tests/test_api.py index c7fbe724..a512034d 100644 --- a/fusion_centralize_billing/tests/test_api.py +++ b/fusion_centralize_billing/tests/test_api.py @@ -61,3 +61,79 @@ class TestApiHandlers(TransactionCase): self.assertTrue(sub.is_subscription) self.assertEqual(sub.plan_id, self.plan) self.assertEqual(sub.subscription_state, '3_progress') + + # ── item 4 (C3): malformed input returns a 4xx-shaped error, never raises ── + def test_record_usage_missing_metric_code_returns_error(self): + self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'}) + partner = self.env['fusion.billing.account.link'].search( + [('external_id', '=', 'client-9')]).partner_id + sub = self.env['sale.order'].sudo().create( + {'partner_id': partner.id, 'is_subscription': True, 'plan_id': self.plan.id}) + # metric_code intentionally omitted — must return an error dict, not raise + res = self.service._api_record_usage({'events': [{ + 'subscription_external_id': str(sub.id), + 'quantity': 10.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01', + }]}) + self.assertEqual(res['status'], 'error') + # no usage row written + usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)]) + self.assertFalse(usage) + + +@tagged('post_install', '-at_install') +class TestUsageAuthorization(TransactionCase): + """/usage must only accept events for subscriptions the calling service is linked + to, and reject unknown / non-subscription targets (items 3/C2/C4).""" + + def setUp(self): + super().setUp() + self.metric = self.env['fusion.billing.metric'].sudo().create( + {'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'}) + self.plan = self.env['sale.subscription.plan'].sudo().create( + {'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'}) + self.service_a = self.env['fusion.billing.service'].sudo().create( + {'name': 'Service A', 'code': 'svc_a'}) + self.service_b = self.env['fusion.billing.service'].sudo().create( + {'name': 'Service B', 'code': 'svc_b'}) + # Service A owns customer + subscription + self.service_a._api_upsert_customer({'external_id': 'cust-a', 'name': 'Cust A'}) + self.partner_a = self.env['fusion.billing.account.link'].search( + [('service_id', '=', self.service_a.id), ('external_id', '=', 'cust-a')]).partner_id + self.sub_a = self.env['sale.order'].sudo().create( + {'partner_id': self.partner_a.id, 'is_subscription': True, 'plan_id': self.plan.id}) + self.Usage = self.env['fusion.billing.usage'].sudo() + + def _event(self, sub_id, idem): + return {'events': [{ + 'subscription_external_id': str(sub_id), 'metric_code': 'api_calls', + 'quantity': 42.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01', + 'idempotency_key': idem, + }]} + + def test_cross_service_usage_rejected(self): + """Service B pushing usage onto Service A's subscription is rejected, no row.""" + res = self.service_b._api_record_usage(self._event(self.sub_a.id, 'cross-1')) + self.assertEqual(res['status'], 'error') + self.assertEqual(res['error'], 'unknown subscription') + self.assertFalse(self.Usage.search([('subscription_id', '=', self.sub_a.id)])) + + def test_same_service_usage_accepted(self): + """Positive control: Service A pushing onto its own subscription is accepted.""" + res = self.service_a._api_record_usage(self._event(self.sub_a.id, 'ok-1')) + self.assertEqual(res['status'], 'ok') + self.assertEqual(res['accepted'], 1) + self.assertTrue(self.Usage.search([('subscription_id', '=', self.sub_a.id)])) + + def test_nonexistent_subscription_rejected(self): + res = self.service_a._api_record_usage(self._event(999_999_999, 'ghost-1')) + self.assertEqual(res['status'], 'error') + self.assertEqual(res['error'], 'unknown subscription') + + def test_non_subscription_order_rejected(self): + """A plain (non-subscription) sale.order owned by the linked customer is rejected.""" + plain = self.env['sale.order'].sudo().create({'partner_id': self.partner_a.id}) + self.assertFalse(plain.is_subscription) + res = self.service_a._api_record_usage(self._event(plain.id, 'plain-1')) + self.assertEqual(res['status'], 'error') + self.assertEqual(res['error'], 'unknown subscription') + self.assertFalse(self.Usage.search([('subscription_id', '=', plain.id)])) diff --git a/fusion_centralize_billing/tests/test_charge.py b/fusion_centralize_billing/tests/test_charge.py index ae33f141..6aabf20b 100644 --- a/fusion_centralize_billing/tests/test_charge.py +++ b/fusion_centralize_billing/tests/test_charge.py @@ -1,5 +1,8 @@ # -*- coding: utf-8 -*- +from psycopg2 import IntegrityError + from odoo.tests.common import TransactionCase, tagged +from odoo.tools.misc import mute_logger @tagged('post_install', '-at_install') @@ -43,3 +46,29 @@ class TestChargeMath(TransactionCase): # 2,001 units -> 3 packages -> $6.00 _, amount = charge._compute_billable(2_001.0) self.assertAlmostEqual(amount, 6.0, places=2) + + # ── item 10 (M7): only the two implemented charge models remain ── + def test_charge_model_selection_limited(self): + field = self.env['fusion.billing.charge']._fields['charge_model'] + keys = [k for k, _label in field.selection] + self.assertEqual(sorted(keys), ['package', 'standard']) + self.assertNotIn('graduated', keys) + self.assertNotIn('volume', keys) + + # ── item 11 (L1): currency is required and defaults to company currency ── + def test_currency_required_and_defaulted(self): + field = self.env['fusion.billing.charge']._fields['currency_id'] + self.assertTrue(field.required) + charge = self._charge() + self.assertEqual(charge.currency_id, self.env.company.currency_id) + + # ── item 12 (L2): non-negative price + positive batch DB constraints ── + def test_negative_price_rejected(self): + with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'): + with self.env.cr.savepoint(): + self._charge(price_per_unit=-1.0) + + def test_zero_batch_rejected(self): + with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'): + with self.env.cr.savepoint(): + self._charge(unit_batch=0.0) diff --git a/fusion_centralize_billing/tests/test_usage.py b/fusion_centralize_billing/tests/test_usage.py index db4ecc1b..c31db95b 100644 --- a/fusion_centralize_billing/tests/test_usage.py +++ b/fusion_centralize_billing/tests/test_usage.py @@ -2,6 +2,66 @@ from odoo.tests.common import TransactionCase, tagged +@tagged('post_install', '-at_install') +class TestRatingCron(TransactionCase): + """The rating cron must only rate a subscription against charges on its OWN plan + (items 1/C1/H4) and over the subscription's real open billing period (item 5/H1).""" + + def setUp(self): + super().setUp() + self.metric = self.env['fusion.billing.metric'].sudo().create( + {'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'}) + self.plan_a = self.env['sale.subscription.plan'].sudo().create( + {'name': 'Plan A', 'billing_period_value': 1, 'billing_period_unit': 'month'}) + self.plan_b = self.env['sale.subscription.plan'].sudo().create( + {'name': 'Plan B', 'billing_period_value': 1, 'billing_period_unit': 'month'}) + self.partner = self.env['res.partner'].sudo().create({'name': 'Acme'}) + self.recurring_product = self.env['product.product'].sudo().create( + {'name': 'Plan seat', 'type': 'service', 'recurring_invoice': True, + 'list_price': 10.0}) + self.overage_product = self.env['product.product'].sudo().create( + {'name': 'CPU overage', 'type': 'service', 'list_price': 0.0}) + self.Usage = self.env['fusion.billing.usage'].sudo() + + def _confirmed_sub(self, plan): + sub = self.env['sale.order'].sudo().create({ + 'partner_id': self.partner.id, 'plan_id': plan.id, + 'order_line': [(0, 0, {'product_id': self.recurring_product.id, + 'product_uom_qty': 1})], + }) + sub.action_confirm() + # widen the computed billing window so usage in May is in-period + sub.write({'start_date': '2026-05-01', 'next_invoice_date': '2026-06-01'}) + return sub + + def test_cron_rates_only_matching_plan(self): + sub_a = self._confirmed_sub(self.plan_a) + sub_b = self._confirmed_sub(self.plan_b) + self.assertEqual(sub_a.subscription_state, '3_progress') + self.assertEqual(sub_b.subscription_state, '3_progress') + # one charge, linked to Plan A only + charge = self.env['fusion.billing.charge'].sudo().create({ + 'name': 'CPU overage', 'plan_code': 'plan-a', 'plan_id': self.plan_a.id, + 'metric_id': self.metric.id, 'product_id': self.overage_product.id, + 'included_quota': 100.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0, + 'charge_model': 'standard'}) + # usage recorded on BOTH subs, in the open period + self.Usage._record_usage(sub_a, 'cpu_seconds', 1100.0, + '2026-05-10 00:00:00', '2026-05-11 00:00:00', idem='a1') + self.Usage._record_usage(sub_b, 'cpu_seconds', 1100.0, + '2026-05-10 00:00:00', '2026-05-11 00:00:00', idem='b1') + + self.Usage._cron_rate_open_periods() + + # Plan A sub IS rated (window captured the usage → overage line present) + line_a = sub_a.order_line.filtered(lambda l: l.product_id == self.overage_product) + self.assertTrue(line_a, "Plan A subscription should be rated by the Plan A charge") + self.assertAlmostEqual(line_a.price_unit, 0.10, places=2) + # Plan B sub is NOT rated by the Plan A charge + line_b = sub_b.order_line.filtered(lambda l: l.product_id == self.overage_product) + self.assertFalse(line_b, "Plan B subscription must NOT be rated by the Plan A charge") + + @tagged('post_install', '-at_install') class TestUsageIngestion(TransactionCase): @@ -65,3 +125,47 @@ class TestUsageIngestion(TransactionCase): self.assertAlmostEqual(amount, 0.10, places=2) line = self.sub.order_line.filtered(lambda l: l.product_id == product) self.assertTrue(line) + + # ── item 6 (H2): half-open aggregation window anchored on period_start ── + def test_aggregate_daily_rollups_in_window(self): + """Three DAILY rollups (period_start 05-01/-08/-15, each period_end +1 day) + sum correctly for the half-open window ['2026-05-01', '2026-06-01').""" + rollups = [ + ('2026-05-01 00:00:00', '2026-05-02 00:00:00', 3.0), + ('2026-05-08 00:00:00', '2026-05-09 00:00:00', 5.0), + ('2026-05-15 00:00:00', '2026-05-16 00:00:00', 7.0), + ] + for i, (ps, pe, q) in enumerate(rollups): + self.Usage._record_usage(self.sub, 'cpu_seconds', q, ps, pe, idem='daily-%d' % i) + total = self.Usage._aggregate( + self.sub, self.metric, '2026-05-01 00:00:00', '2026-06-01 00:00:00') + self.assertEqual(total, 15.0) # 3 + 5 + 7 + + # ── item 7 (H3): idempotency key is scoped per (subscription, metric) ── + def test_same_idempotency_key_distinct_subscriptions(self): + """The SAME idempotency key on two DIFFERENT subscriptions creates TWO rows.""" + sub2 = self.env['sale.order'].sudo().create({ + 'partner_id': self.partner.id, 'is_subscription': True, 'plan_id': self.plan.id, + }) + key = 'shared-idem-key' + a = self.Usage._record_usage(self.sub, 'cpu_seconds', 10.0, '2026-05-01', '2026-06-01', idem=key) + b = self.Usage._record_usage(sub2, 'cpu_seconds', 20.0, '2026-05-01', '2026-06-01', idem=key) + self.assertNotEqual(a, b) # distinct rows, no collision + rows = self.Usage.search([('idempotency_key', '=', key)]) + self.assertEqual(len(rows), 2) + self.assertEqual(a.quantity, 10.0) + self.assertEqual(b.quantity, 20.0) + + # ── item 2 (C1): zero aggregated usage creates no overage line ── + def test_zero_usage_creates_no_line(self): + product = self.env['product.product'].sudo().create( + {'name': 'API overage', 'type': 'service', 'list_price': 0.0}) + charge = self.env['fusion.billing.charge'].sudo().create({ + 'name': 'overage', 'plan_code': 'p', 'metric_id': self.metric.id, + 'product_id': product.id, 'included_quota': 100.0, + 'price_per_unit': 0.10, 'unit_batch': 1000.0, 'charge_model': 'standard'}) + # no usage recorded → aggregate is 0 → amount 0 → no line created + amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01') + self.assertEqual(amount, 0.0) + line = self.sub.order_line.filtered(lambda l: l.product_id == product) + self.assertFalse(line) diff --git a/fusion_centralize_billing/tests/test_webhook.py b/fusion_centralize_billing/tests/test_webhook.py index aa8e9731..38e73cab 100644 --- a/fusion_centralize_billing/tests/test_webhook.py +++ b/fusion_centralize_billing/tests/test_webhook.py @@ -4,6 +4,7 @@ import hmac import json from unittest.mock import patch +from odoo.exceptions import ValidationError from odoo.tests.common import TransactionCase, tagged @@ -51,3 +52,48 @@ class TestWebhookEngine(TransactionCase): return_value=_Resp()): self.Webhook._cron_dispatch() self.assertEqual(wh.state, 'dead') + + # ── item 8 (H5): dispatch POSTs the stored body verbatim + event-id header ── + def test_dispatch_posts_stored_body_and_event_id(self): + wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-9'}) + + class _Resp: + status_code = 200 + text = 'ok' + + with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post', + return_value=_Resp()) as mock_post: + self.Webhook._cron_dispatch() + self.assertTrue(mock_post.called) + _args, kwargs = mock_post.call_args + # the exact stored body is POSTed (not a re-serialized payload) + self.assertEqual(kwargs['data'], wh.body) + self.assertEqual(wh.body, json.dumps( + {'invoice': 'INV-9'}, sort_keys=True, separators=(',', ':'))) + # signature matches the bytes on the wire + expected = hmac.new(b'whsec_test', wh.body.encode(), hashlib.sha256).hexdigest() + self.assertEqual(kwargs['headers']['X-Fusion-Signature'], expected) + # event id header present and correct + self.assertEqual(kwargs['headers']['X-Fusion-Event-Id'], str(wh.id)) + + # ── item 9 (H6): SSRF guard on webhook_url ── + def test_webhook_url_rejects_loopback(self): + with self.assertRaises(ValidationError): + self.env['fusion.billing.service'].sudo().create({ + 'name': 'Evil', 'code': 'evil', 'webhook_url': 'http://127.0.0.1/x'}) + + def test_webhook_url_rejects_private_and_http(self): + for bad in ('http://10.0.0.5/hook', # private + non-https + 'https://192.168.1.10/hook', # private + 'https://localhost/hook', # localhost host + 'https://169.254.169.254/latest', # link-local metadata + 'http://api.example.com/hook'): # non-https + with self.assertRaises(ValidationError): + self.env['fusion.billing.service'].sudo().create({ + 'name': 'Bad', 'code': 'bad-%s' % bad, 'webhook_url': bad}) + + def test_webhook_url_allows_public_https(self): + svc = self.env['fusion.billing.service'].sudo().create({ + 'name': 'Good', 'code': 'good', + 'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook'}) + self.assertTrue(svc.id) From 43b2edcbb55d9ddee2ffe6d6672e1e6b139c3a60 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 27 May 2026 08:56:28 -0400 Subject: [PATCH 15/37] =?UTF-8?q?@=20docs(billing):=20session=20handoff=20?= =?UTF-8?q?=E2=80=94=20core=20on=20main,=20sub-project=20#2=20(NexaCloud)?= =?UTF-8?q?=20next?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures resume state for the centralized-billing initiative: core engine done and on main, the 4-chunk decomposition of sub-project #2 (NexaCloud adapter + dual-run reconciliation), the pending "where to start" decision, open questions, and the test/branch workflow. Co-Authored-By: Claude Opus 4.7 (1M context) @ --- ...26-05-27-fusion-billing-session-handoff.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/superpowers/2026-05-27-fusion-billing-session-handoff.md diff --git a/docs/superpowers/2026-05-27-fusion-billing-session-handoff.md b/docs/superpowers/2026-05-27-fusion-billing-session-handoff.md new file mode 100644 index 00000000..d4ae7c85 --- /dev/null +++ b/docs/superpowers/2026-05-27-fusion-billing-session-handoff.md @@ -0,0 +1,75 @@ +# fusion_centralize_billing — Session Handoff (2026-05-27) + +Resume point for the centralized-billing initiative. Read this first, then continue +from **"Decision pending"** below. + +## Where we are + +- **Sub-project #1 (core billing engine): DONE and on `main`** (tip `d770c0c3`, pushed to + GitHub + Gitea). + - 11/11 plan tasks, TDD, Opus code-reviewed; all Critical/High bugs fixed + (cross-billing cron → match by `plan_id`; `/usage` authz vs IDOR; input validation → + 4xx not 500; correct billing-period window; idempotency scoped to `(sub, metric, key)`; + webhook sign-exact-bytes + event-id + SSRF guard). + - **39 tests green on Odoo 19 Enterprise.** + - Note: the 14 billing commits were rebased off the old login-audit/helpdesk stack and + landed cleanly on `main`. `fusion_login_audit` was deliberately **not** landed — it + stays on `feat/fusion-login-audit`. A concurrent `feat/helpdesk-customer-followup` + session still carries a pre-landing copy of the billing commits; when it merges, replay + its helpdesk-only commits onto `main`. + +- **Reference docs (on `main`):** + - Spec: `docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md` + - Core plan: `docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md` + +## Next: sub-project #2 — NexaCloud adapter + dual-run reconciliation + +Per spec §12, each sub-project is its own spec → plan → build cycle. #2 decomposes into +four chunks (dependency order): + +| Chunk | What | Risk | +|-------|------|------| +| **2a — Mapping + importer** | Read `nexacloud` DB → create `res.partner` + `account.link`, `product.template` + subscription plans, one subscription `sale.order` per deployment | **Low** — read-only on NexaCloud, writes only into Odoo | +| **2b — Usage metering wiring** | NexaCloud `usage_metering.py` pushes CPU-seconds → Odoo `/usage`; verify aggregation → draft invoice w/ quota + overage + HST | Edits NexaCloud code | +| **2c — Control loop** | NexaCloud consumes Odoo's outbound webhooks (`invoice.payment_failed` → suspend via existing `network_isolation`/`throttle_checker`; `subscription.terminated` → deprovision) | Edits NexaCloud code | +| **2d — Dual-run reconciliation** | `fusion.billing.reconciliation` diffs Odoo-computed vs NexaCloud-actual per customer/period for ≥ 1 cycle before any flip | Safety gate before flipping real billing | + +The core engine already built the *receiving* side (`/usage`, webhook engine, charge math). +#2 is about **connecting NexaCloud to it and proving the numbers match before flipping.** + +## Decision pending (resume here) + +We were in the `superpowers:brainstorming` flow for #2 and stopped at: **which slice to +start with?** + +- **(recommended) 2a — Mapping + importer** — lowest risk, foundation for everything else. +- 2d — Reconciliation first (front-load the trust mechanism). +- Full #2 design as one spec, then one plan. +- Just write the #2 plan, no code this session. + +## Open questions to resolve before building #2 + +- **Spec §15 Q2 — NexaCloud billing granularity:** confirm **one subscription per + deployment** (spec leans this way) vs one subscription per customer with deployment line + items. +- **Access / environments needed:** + - Read access to the `nexacloud` DB schema (LXC 102 / its Postgres on LXC 201) to design + the importer mapping. + - A NexaCloud staging or safe path for 2b/2c (they edit live NexaCloud code). + - Test target for the Odoo side stays the odoo-trial Enterprise sandbox. +- **Resolved already:** Stripe is one account (`acct_1ShlA9IkwUB1dVox`) for everything — no + account migration (spec §11 / §15 Q1). Branch strategy — land on `main`, branch new work + off `main`. + +## How to run / test + +- **Billing tests:** `bash scripts/fcb_test_on_trial.sh` from repo root → pass = `FCB_EXIT=0` + (~1–2 min). Syncs the module to the odoo-trial Enterprise sandbox (Proxmox VM 316, db + `trial`) and runs `--test-enable`. Local dev Odoo is Community and **cannot** install this + module. + +## Branch hygiene (lesson from this session) + +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. From 01a85c475cec015ccd1befb15b1a9abda5267c15 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 26 May 2026 19:52:44 -0400 Subject: [PATCH 16/37] docs(spec): fusion_login_audit design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Durable login audit for Odoo 19 (westin-v19). Captures successful and failed authentications via _update_last_login / _check_credentials / _login overrides, surfaces history on res.users as a smart button + "Login Activity" tab (admins-only), async geo-enriches IPs via ip-api.com through network_logger, 365-day retention with daily GC cron, and emails Settings admins on N consecutive failures for the same login within a configurable window. Motivation: a spot audit of GSA Accounting (uid 63) showed Odoo's res_users_log keeps only one row per user (rest is GC'd), /var/log/odoo is empty (warn-level stdout logging), and the container json log rotates within days — leaving no durable login trail. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-26-fusion-login-audit-design.md | 444 ++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md diff --git a/docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md b/docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md new file mode 100644 index 00000000..3ee9c00b --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md @@ -0,0 +1,444 @@ +# Fusion Login Audit — Design Spec + +**Status:** Approved, ready for implementation planning +**Date:** 2026-05-26 +**Author:** Brainstormed with the user (Gurpreet) for the Westin Healthcare Odoo 19 deployment +**Target module path:** `K:\Github\Odoo-Modules\fusion_login_audit\` +**Production deploy target:** `/opt/odoo/custom-addons/fusion_login_audit/` on `odoo-westin` (VM 101, worker1, 192.168.1.40) +**Production DB:** `westin-v19` (Odoo 19, PostgreSQL) + +## Background and motivation + +A spot audit of user `info@gsafinancialconsulting.com` ("GSA Accounting", uid 63) revealed Odoo's built-in login tracking is effectively unusable for compliance: + +- `res.users.log` rows are pruned by the daily `_gc_user_logs` cron — only the most recent login per user survives. For GSA Accounting the entire history collapsed to a single row at `2026-04-22 20:24 EDT`. +- `/var/log/odoo` on the production VM is empty because Odoo is configured at `log_level=warn` with stdout-only logging; INFO-level auth lines aren't captured anywhere. +- The container's json log is 444 KB and rotates frequently — nothing about the user remains. +- The existing `network_logger` module records outbound HTTP traffic from Odoo (uid=1 always), not user activity. + +Result: today there is **no durable record** of who logged in, when, from where, or how often. A user with `base.group_system` + Technical Features and no 2FA — like GSA Accounting — could be active for months without any reconstructable trail. + +This module closes that gap with a dedicated audit table that survives Odoo's GC, captures successful and failed authentications, surfaces results in the user form, and alerts admins on suspicious failure bursts. + +## Goals + +1. **Durable audit trail** of every password-authenticated login (success and failure) on `westin-v19`. +2. **Per-user visibility** for Settings admins via a tab + smart button on `res.users`. +3. **Failure-burst alerting** to admins on a configurable consecutive-failure threshold. +4. **Geo-enrichment** of IPs out-of-band so authentication latency is unaffected. +5. **Zero risk to the auth path** — an audit-write failure must never block a real login. + +## Non-goals (v1) + +- Logging every HTTP request / page view (explicitly de-scoped during brainstorming). +- Logging session resume events from auth cookies. +- API-key authentication (`credential['type'] == 'apikey'`) — bypasses `_check_credentials`. Documented as a known gap; addressable in a follow-up. +- OAuth / SSO logins — no OAuth provider configured on westin-v19. +- Self-service "view my own login activity" for end users — visibility is admin-only. +- Auto-disabling users on failed logins — flagged as a self-service DoS vector during brainstorming. + +## Architecture overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Odoo authentication path │ +│ │ +│ /web/login → res.users._login() → res.users._check_credentials() │ +│ ↓ │ +│ (on success) │ +│ ↓ │ +│ res.users._update_last_login() │ +│ ↓ │ +│ ┌────────────────────┴────────────────────┐ │ +│ │ │ │ +│ ▼ ▼ │ +│ fusion.login.audit (sudo create) Odoo's existing res_users_log │ +│ result='success' + IP + UA │ +│ │ +│ (on AccessDenied) │ +│ ↓ │ +│ fusion.login.audit (sudo create) │ +│ result='failure' + failure_reason + attempted_login │ +│ ↓ │ +│ _fc_recent_failure_count() >= threshold? │ +│ ↓ yes │ +│ _fc_send_failure_alert() → mail.mail to base.group_system │ +└──────────────────────────────────┬──────────────────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + ▼ ▼ ▼ + cron: cron_geo_enrich cron: cron_retention_gc UI surfaces: + every 5 min daily 03:00 UTC - smart button on res.users + - reverse DNS - delete rows older than - "Login Activity" tab + - ip-api.com lookup x_fc_login_audit_ - Settings → Technical → + - 30-day local cache retention_days Login Audit menus + - Settings page section +``` + +The auth-path hooks are synchronous (must run inside the request). Geolocation, alerting, and retention are out-of-band so they cannot affect login latency. + +## Module skeleton + +``` +fusion_login_audit/ +├── __manifest__.py +├── __init__.py +├── models/ +│ ├── __init__.py +│ ├── res_users.py # extends res.users with capture hooks + computed fields + smart-button action +│ ├── fusion_login_audit.py # the new audit record model +│ └── res_config_settings.py # alert threshold + window + retention settings +├── data/ +│ ├── ir_cron_data.xml # cron_geo_enrich + cron_retention_gc +│ └── mail_template_data.xml # failed-login alert template +├── security/ +│ ├── security.xml # record rule: read for base.group_system only +│ └── ir.model.access.csv +├── views/ +│ ├── fusion_login_audit_views.xml # list / form / kanban / search +│ ├── res_users_views.xml # tab + smart button +│ ├── res_config_settings_views.xml # Settings section +│ └── menus.xml # Settings → Technical → Login Audit +├── tests/ +│ ├── __init__.py +│ ├── test_login_audit.py +│ └── test_security.py +└── static/ + └── description/ + └── icon.png # copied from C:\Users\gsing\Downloads\fusion logs.png +``` + +**Manifest highlights** + +- `version='19.0.1.0.0'` (project naming convention) +- `license='OPL-1'` (matches `fusion_accounts`) +- `depends=['base', 'mail']` +- `category='Tools'` +- `application=False` (it's a technical addon, not a top-level app) + +**Dependencies (Python):** none new. Uses the `user_agents` library already shipped with Odoo. Geolocation calls `http://ip-api.com/json/` via the standard `requests` library (no API key required, 45 req/min free tier). + +**Field naming:** new fields on existing models (`res.users`, `res.config.settings`) use the `x_fc_*` prefix per project CLAUDE.md. The new `fusion.login.audit` model uses unprefixed field names. + +## Data model + +### `fusion.login.audit` (new model, table `fusion_login_audit`) + +| Field | Type | Notes | +|---|---|---| +| `user_id` | Many2one(`res.users`, `ondelete='set null'`) | Null if attempted login didn't match any user | +| `attempted_login` | Char(255), indexed | Always set — even on unknown-user failures | +| `result` | Selection(`success`, `failure`) | Indexed | +| `failure_reason` | Selection(`bad_password`, `unknown_user`, `disabled_user`, `2fa_failed`, `other`) | Null on success | +| `event_time` | Datetime, indexed, default `fields.Datetime.now()` | UTC; displayed in user TZ via standard widget | +| `ip_address` | Char(45) | IPv6-safe length | +| `ip_hostname` | Char(255) | Reverse DNS, populated by geo cron | +| `country_code` | Char(2), indexed | ISO-3166-1 alpha-2; null until cron runs | +| `country_name` | Char(64) | | +| `city` | Char(128) | | +| `geo_state` | Char(64) | Region/state name | +| `geo_lookup_state` | Selection(`pending`, `done`, `private_ip`, `internal`, `failed`) | Drives the geo cron worklist; `internal` = no HTTP request was attached | +| `user_agent_raw` | Char(512) | The full UA header | +| `browser` | Char(64) | e.g. "Chrome 140" — parsed | +| `os` | Char(64) | e.g. "Windows 11" — parsed | +| `device_type` | Selection(`desktop`, `mobile`, `tablet`, `bot`, `unknown`) | From `user_agents` | +| `database` | Char(64) | Multi-DB safety — which DB was logged into | + +**Indexes (in addition to the column-level `indexed=True`):** +- `(user_id, event_time DESC)` — per-user history +- `(attempted_login, event_time DESC)` — failure-burst detection by login string +- `(geo_lookup_state, event_time)` — cron worklist + +**No `_inherit = ['mail.thread']`** — audit rows are append-only and should not have chatter. + +### `res.users` additions (per CLAUDE.md `x_fc_*` convention) + +| Field | Type | Notes | +|---|---|---| +| `x_fc_login_audit_ids` | One2many(`fusion.login.audit`, `user_id`) | Backs the tab + smart-button count | +| `x_fc_login_audit_count` | Integer, compute, store=False | Smart-button label | +| `x_fc_last_successful_login` | Datetime, compute, store=True | Indexed; cheap "last seen" lookup | +| `x_fc_last_login_ip` | Char(45), compute, store=True | Surfaces last source IP in the form header | + +The `store=True` computes are triggered by the create on `fusion.login.audit` (via `@api.depends('x_fc_login_audit_ids.event_time', 'x_fc_login_audit_ids.result')`). + +### `res.config.settings` additions + +Booleans / integers only (per CLAUDE.md — no Date fields on settings): + +| Field | Default | Notes | +|---|---|---| +| `x_fc_login_audit_retention_days` | 365 | Retention GC cron honors this; 0 = keep forever | +| `x_fc_login_audit_alert_threshold` | 5 | Consecutive failures before alert | +| `x_fc_login_audit_alert_window_min` | 15 | Time window in minutes for "consecutive" | +| `x_fc_login_audit_alert_enabled` | True | Master kill-switch for alert emails | + +Each is backed by an `ir.config_parameter` (`fusion_login_audit.retention_days`, etc.) so changes from the Settings page persist. + +### Multi-company + +`fusion.login.audit` is intentionally **company-agnostic**. Logins happen before any company context is established; synthesizing one would either break the unknown-user case or require a "system company" placeholder. Settings admins see all rows globally. + +## Capture flow + +### Successful login (`_update_last_login`) + +```python +def _update_last_login(self): + result = super()._update_last_login() + try: + self._fc_record_login_event(result='success') + except Exception: + _logger.exception("fusion_login_audit: failed to record success row for %s", self.login) + return result +``` + +Called by Odoo only after the credential check has passed. Super() runs first so Odoo's own bookkeeping is unaffected. + +### Failed login on known user (`_check_credentials`) + +```python +def _check_credentials(self, credential, env): + try: + return super()._check_credentials(credential, env) + except AccessDenied: + try: + self._fc_record_login_failure(credential, reason='bad_password') + if self._fc_recent_failure_count(credential) >= self._fc_alert_threshold(): + self._fc_send_failure_alert(credential) + except Exception: + _logger.exception("fusion_login_audit: failed to record/alert failure") + raise +``` + +TOTP failures (from `auth_totp`) also raise `AccessDenied` and are caught here. Distinguish via `credential.get('type') == 'totp'` to set `failure_reason='2fa_failed'`. + +### Failed login on unknown user (`_login` classmethod) + +```python +@classmethod +def _login(cls, db, credential, user_agent_env): + try: + return super()._login(db, credential, user_agent_env) + except AccessDenied: + try: + cls._fc_record_unknown_user_failure(db, credential, user_agent_env) + except Exception: + _logger.exception("fusion_login_audit: failed to record unknown-user failure") + raise +``` + +Without this override, unknown-user attempts never reach `_check_credentials` and would silently disappear from the audit. The classmethod sets `user_id=None` and stores the attempted login string. + +### Context extraction (`_fc_build_event_vals`) + +Single helper shared by all three paths: + +```python +def _fc_build_event_vals(self, result, attempted_login, failure_reason=None): + from odoo.http import request + vals = { + 'attempted_login': attempted_login, + 'result': result, + 'failure_reason': failure_reason, + 'event_time': fields.Datetime.now(), + 'database': self.env.cr.dbname, + 'geo_lookup_state': 'pending', + } + if request and request.httprequest: + vals['ip_address'] = request.httprequest.remote_addr # respects proxy_mode + ua_str = request.httprequest.user_agent.string or '' + vals['user_agent_raw'] = ua_str[:512] + from user_agents import parse as ua_parse + ua = ua_parse(ua_str) + vals['browser'] = f"{ua.browser.family} {ua.browser.version_string}"[:64] + vals['os'] = f"{ua.os.family} {ua.os.version_string}"[:64] + vals['device_type'] = ( + 'mobile' if ua.is_mobile else + 'tablet' if ua.is_tablet else + 'bot' if ua.is_bot else + 'desktop' if ua.is_pc else 'unknown' + ) + else: + vals['ip_address'] = 'internal' + vals['user_agent_raw'] = '' + vals['geo_lookup_state'] = 'internal' # distinct from private_ip; cron skips both + return vals +``` + +### Write semantics + +- All writes use `self.env['fusion.login.audit'].sudo().create(vals)` — low-privilege users can still generate their own audit rows despite the read-only record rule. +- `mail_create_nolog=True` context to avoid chatter noise. +- The password value is **never** present in `vals` and is hard-stripped from any `credential` dict before logging. A regression test asserts this. + +## Async geolocation cron (`cron_geo_enrich`) + +**Schedule:** every 5 minutes, `numbercall=-1`, `priority=10`. + +**Worker logic:** + +1. Select 100 oldest rows where `geo_lookup_state='pending'`. +2. For each row: + - **Private-IP shortcut:** if `ip_address` is in `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `::1`, or `fe80::/10` → set `geo_lookup_state='private_ip'`, `country_code='--'`, `city='Private network'`. + - **Cache check:** look for any prior row with the same `ip_address` and `country_code IS NOT NULL` and `event_time > now() - interval '30 days'`. If found, copy `country_code` / `country_name` / `city` / `geo_state` / `ip_hostname` locally; set state `done`. No external call. + - **Reverse DNS:** `socket.gethostbyaddr(ip)` with `socket.setdefaulttimeout(1.5)`. + - **HTTP lookup:** `requests.get('http://ip-api.com/json/' + ip, params={'fields': 'status,country,countryCode,regionName,city'}, timeout=3, headers={'User-Agent': 'Odoo-FusionLoginAudit/19.0'})`. The call passes through `network_logger` automatically. + - On `status='success'` → fill fields, set state `done`. + - On HTTP error, timeout, or `status='fail'` → set state `failed` (no retry). +3. `self.env.cr.commit()` after each row so one bad IP cannot roll back the batch. +4. **Rate limit defense:** if the response header `X-Rl` is `'0'`, break early and leave remaining rows as `pending` for the next run. + +**Privacy:** the only outbound data is the IP itself. No user identifiers, no Odoo URL, no headers beyond `User-Agent: Odoo-FusionLoginAudit/19.0`. All outbound calls are auditable in `network_logger`. + +## UI surfaces + +### `res.users` form view + +- **Smart button** in the button box, gated `groups="base.group_system"`: + ``` + ┌──────────────┐ + │ 🔑 N Logins │ + └──────────────┘ + ``` + Click → opens `fusion.login.audit` list view filtered to this user (`domain=[('user_id', '=', active_id)]`). +- **New tab "Login Activity"** appended after existing tabs, gated `groups="base.group_system"`: + - Header summary: `x_fc_last_successful_login`, `x_fc_last_login_ip` (readonly). + - Embedded one2many tree on `x_fc_login_audit_ids`, `limit="30"`, columns: `event_time`, `result` (colored badge), `ip_address`, `country_code` (with flag emoji display), `browser`, `os`, `failure_reason`. + - Tree is `create="false" edit="false" delete="false"`. + - "View full history →" button below the tree, same action as the smart button. + +### Standalone views for `fusion.login.audit` + +- **List view:** `event_time`, `user_id` (clickable), `attempted_login` (only when `user_id IS NULL`), `result` badge, `ip_address`, `country_code`, `city`, `browser`, `device_type`. Default sort `event_time DESC`. +- **Search view:** filters for "Successes", "Failures", "Last 24h", "Last 7d", "Last 30d", "Unknown users (no user_id)"; group-by IP / country / user. +- **Form view:** readonly; collapsible "Raw" section for `user_agent_raw`, `ip_hostname`, `database`, `geo_lookup_state`. +- **Kanban view:** grouped by `result`, color-coded green/red. + +### Menus + +Under **Settings → Technical → Login Audit**: +- "Login Events" → default list view +- "Failed Logins (24h)" → list view with default `[('result', '=', 'failure'), ('event_time', '>=', context_today() - 1)]` + +### Settings page + +New "Login Audit" section in **Settings → General Settings** (gated `groups="base.group_system"`): +- "Retention period (days)" — integer, help: "0 = keep forever" +- "Alert threshold" — integer +- "Alert window (minutes)" — integer +- "Send failed-login alerts" — boolean + +## Security + +### Group + +No new group created. Read is bound to existing `base.group_system`. Rationale: brainstorming decision was "Settings admins only" — reusing the existing group avoids an extra checkbox to manage. + +### Model access (`ir.model.access.csv`) + +| Group | Read | Write | Create | Unlink | +|---|---|---|---|---| +| `base.group_system` | ✓ | ✗ | ✗ | ✗ | + +**No write/create/unlink for any group via the UI.** Audit rows are only written via `sudo()` from inside the auth hooks. An audit log admins can mutate is not an audit log. + +### Record rule + +Single global rule on `fusion.login.audit`: read for `base.group_system` only. The user-form one2many is additionally gated at the view level via `groups="base.group_system"` (not via a more permissive record rule) so non-admins have no read path even if they craft a custom view. + +### Field-level + +- `failure_reason` stores a category, never the attempted password. +- `_fc_build_event_vals` strips `credential['password']` before any logging or row construction. +- The `credential` dict is never persisted. +- Regression test: no field on `fusion.login.audit` ever contains a known-test-password string. + +## Retention + +**Cron `cron_retention_gc`** — daily at 03:00 UTC, `numbercall=-1`: + +```python +days = int(self.env['ir.config_parameter'].sudo().get_param( + 'fusion_login_audit.retention_days', 365)) +if days > 0: + cutoff = fields.Datetime.now() - timedelta(days=days) + self.env['fusion.login.audit'].sudo().search([ + ('event_time', '<', cutoff) + ]).unlink() +``` + +Uses `unlink()` rather than raw `DELETE` so any ORM side effects fire. Expected DB load on `westin-v19`: 27 users × ~2 logins/day × 365 days ≈ 20k rows steady state — trivial for Postgres. + +## Failed-login alert + +**Mail template** in `data/mail_template_data.xml`: + +- **Subject:** `[Login Audit] {threshold} failed login attempts for {attempted_login}` +- **Body:** simple HTML table of the last N failure rows for that `attempted_login` — timestamp, IP, country, user-agent summary. +- **Recipients:** all users in `base.group_system` with a non-empty `email`. +- **Send path:** `mail.mail` queue with `auto_delete=True` so the auth response isn't blocked. + +**Cooldown:** 60 min per `attempted_login`, enforced via an `ir.config_parameter` keyed by `fusion_login_audit.last_alert:{attempted_login}` storing the last-send timestamp. Prevents a sustained attack from flooding admin inboxes. + +**Kill-switch:** if `x_fc_login_audit_alert_enabled = False`, no alerts are sent regardless of threshold. + +## Edge cases + +| Case | Behavior | +|---|---| +| `request` is None (XML-RPC, internal auth from cron) | Row written with `ip_address='internal'`, `user_agent_raw=''`, `geo_lookup_state='internal'` (cron skips) | +| Audit insert errors on a hot DB | Login still succeeds — every auth-path hook is wrapped in `try/except Exception: _logger.exception(...)` | +| User deleted while audit rows remain | `ondelete='set null'` preserves history; `attempted_login` keeps the readable identifier | +| Password reset / `auth_signup` | The reset itself generates no login event; the subsequent login does — matches expectation | +| API key authentication | **Out of scope v1** (bypasses `_check_credentials`); documented | +| OAuth / SSO | Out of scope v1; no provider configured on westin-v19 | +| Portal user (`share=True`) | Logged the same way; smart button remains admin-visible | +| Two requests racing on the same private IP | Each writes its own row; geo cache is best-effort, not transactional | +| `proxy_mode = False` in `odoo.conf` | `remote_addr` will be the reverse-proxy IP — known limitation, fixable by setting `proxy_mode = True` (out of scope) | + +## Testing + +### `tests/test_login_audit.py` (TransactionCase) + +1. Successful login writes a row with `result='success'` and resolved `user_id`. +2. Bad password writes `result='failure'` with `failure_reason='bad_password'` and re-raises `AccessDenied`. +3. Unknown user writes `result='failure'` with `failure_reason='unknown_user'`, `user_id=None`, non-null `attempted_login`. +4. No field on the written row contains the attempted password (regression). +5. Geo cron: pending row gets enriched from local cache when same IP exists within 30 days (no HTTP call made). +6. Retention cron: rows older than `retention_days` are deleted; newer survive. +7. Alert email: 5 failures in 15 min queues exactly one `mail.mail`; a 6th failure within cooldown queues zero. +8. `database` field is populated from `self.env.cr.dbname`. +9. Audit-write exception inside `_update_last_login` does not block the login. + +### `tests/test_security.py` (HttpCase) + +1. Non-admin user gets `AccessError` on direct `search(fusion.login.audit)`. +2. Non-admin sees the user form view without the smart button or "Login Activity" tab (XML node hidden by `groups`). +3. Settings admin sees both. + +## Deployment notes + +- **Local install:** copy module to `K:\Github\Odoo-Modules\fusion_login_audit\` (bind-mounted into `odoo-modsdev-app` container). Update via: + ``` + docker exec odoo-modsdev-app odoo -d fusion-dev -i fusion_login_audit --stop-after-init + ``` +- **Production install:** sync to `/opt/odoo/custom-addons/fusion_login_audit/` on odoo-westin (via `auto_sync.sh` or git pull on the VM). Update via: + ``` + ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -i fusion_login_audit --stop-after-init" + ``` +- **Icon:** copy `C:\Users\gsing\Downloads\fusion logs.png` to `K:\Github\Odoo-Modules\fusion_login_audit\static\description\icon.png`. +- **Verify `proxy_mode = True`** in `/opt/odoo/odoo.conf` on odoo-westin before relying on `ip_address` accuracy — otherwise `remote_addr` will be the reverse-proxy IP rather than the real client. Confirmed out of scope for this module, but flag for the operator. +- **Verify outbound to `ip-api.com:80`** is reachable from the odoo-westin VM (Tailscale/firewall) — if blocked, `geo_lookup_state` will simply be `failed` and the rest of the module is unaffected. + +## Success criteria + +- Logging in as any user creates exactly one `fusion.login.audit` row with `result='success'` and the correct IP/UA. +- Failed login attempts create exactly one row with `result='failure'` and the correct `failure_reason`. +- Unknown-user attempts create a row with `user_id=None` and the typed login string in `attempted_login`. +- The smart button on `res.users` shows the lifetime count and opens the filtered list. +- The "Login Activity" tab shows the last 30 events with correct color coding. +- After 5 failures from the same login string within 15 minutes, exactly one alert email arrives in the inbox of every Settings admin with an `email` set. +- The geo cron populates `country_code`, `city`, `ip_hostname` for public IPs within 10 minutes of the login. +- The retention cron, set to 1 day for a test, deletes rows older than 24 hours and leaves newer ones. +- All tests pass: `docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable -i fusion_login_audit --stop-after-init`. From a32946be44e3f8a3489f346e0a37855c53b4de31 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 26 May 2026 20:02:11 -0400 Subject: [PATCH 17/37] docs(plan): fusion_login_audit implementation plan 15 TDD tasks targeting ~28 tests: T1 skeleton+icon, T2 model+indexes, T3 security, T4 capture helper, T5 success hook, T6 bad-password hook, T7 unknown-user hook, T8 user form (smart button + tab + computes), T9 standalone views + menus, T10 settings + page section, T11 failure-burst alert + cooldown, T12 retention GC cron, T13 async geo enrichment cron, T14 view visibility security tests, T15 manual smoke + release tag. Self-reviewed: every spec section maps to a task; no placeholders; method and field names consistent across tasks. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-26-fusion-login-audit.md | 2694 +++++++++++++++++ 1 file changed, 2694 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-26-fusion-login-audit.md diff --git a/docs/superpowers/plans/2026-05-26-fusion-login-audit.md b/docs/superpowers/plans/2026-05-26-fusion-login-audit.md new file mode 100644 index 00000000..6ab3eda5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-fusion-login-audit.md @@ -0,0 +1,2694 @@ +# fusion_login_audit Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a durable Odoo 19 login audit module for `westin-v19` that captures every successful and failed authentication, surfaces the history on the `res.users` form, async-enriches IPs with geolocation, deletes rows past a configurable retention horizon, and emails Settings admins on consecutive-failure bursts. + +**Architecture:** A new module `fusion_login_audit` at `K:\Github\Odoo-Modules\fusion_login_audit\`. Hooks Odoo's auth path via `_update_last_login` (success), `_check_credentials` (known-user failure), and `_login` (unknown-user failure). Writes append-only rows to a dedicated `fusion.login.audit` table via `sudo()`. Out-of-band crons handle geolocation, retention, and alert-cooldown bookkeeping. + +**Tech Stack:** Odoo 19, Python 3, `user_agents` library (bundled with Odoo), PostgreSQL. Geolocation via `http://ip-api.com/json/` (free tier, no key, 45 req/min) routed through the existing `network_logger`. + +**Reference spec:** `docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md` + +--- + +## Pre-flight conventions + +These apply to every task. Read once, internalize, then execute. + +- **NEVER code from memory.** Before writing any new Odoo class/method, read the reference file from the container: + ```bash + docker exec odoo-modsdev-app cat /usr/lib/python3/dist-packages/odoo/addons/base/models/res_users.py | head -200 + ``` +- **Module location:** `K:\Github\Odoo-Modules\fusion_login_audit\` (Windows paths) which is bind-mounted into the `odoo-modsdev-app` container at `/mnt/extra-addons/fusion_login_audit/` (or whatever path the compose file specifies — verify with `docker inspect odoo-modsdev-app --format '{{json .Mounts}}' | python -m json.tool`). +- **Field naming:** new fields on `res.users` and `res.config.settings` use the `x_fc_*` prefix. Fields on the new `fusion.login.audit` model use plain names. +- **Settings field types:** booleans/integers/floats/char/selection/many2one/datetime only on `res.config.settings`. No Date fields. (`x_fc_login_audit_retention_days` is an Integer — not a Date.) +- **`res.groups`:** never use `users=` or `category_id=`. +- **HTTP routes:** if any are added, use `type="jsonrpc"` not `type="json"`. +- **Canadian English** for all user-facing strings ("Authorise", "Centre", "behaviour", etc.). +- **Test command shape:** + ```bash + docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -i fusion_login_audit --stop-after-init 2>&1 | tail -60 + ``` + First run uses `-i fusion_login_audit` (install). Subsequent runs after code changes use `-u fusion_login_audit` (update). Expected to take 15-40 seconds per run. +- **Commit cadence:** every task ends with a commit. Branch is already `feat/fusion-login-audit` (cut from `main` at sha cc26b9ad). Never push without an explicit user request. +- **Commit message footer:** + ``` + Co-Authored-By: Claude Opus 4.7 (1M context) + ``` + +--- + +## File map + +| Path | Responsibility | +|---|---| +| `fusion_login_audit/__manifest__.py` | Module metadata + data file declarations | +| `fusion_login_audit/__init__.py` | Imports `models` and `tests` | +| `fusion_login_audit/models/__init__.py` | Imports the three model modules | +| `fusion_login_audit/models/fusion_login_audit.py` | The audit record model — fields only, no behaviour | +| `fusion_login_audit/models/res_users.py` | All capture hooks (`_update_last_login`, `_check_credentials`, `_login`), helpers (`_fc_build_event_vals`, `_fc_record_*`, `_fc_recent_failure_count`, `_fc_send_failure_alert`), computed fields, smart-button action | +| `fusion_login_audit/models/res_config_settings.py` | Settings fields with ICP-backed getters/setters | +| `fusion_login_audit/data/ir_cron_data.xml` | `cron_geo_enrich` (5 min) + `cron_retention_gc` (daily 03:00 UTC) | +| `fusion_login_audit/data/mail_template_data.xml` | Failed-login alert email template | +| `fusion_login_audit/security/ir.model.access.csv` | Model access — read-only for `base.group_system` | +| `fusion_login_audit/security/security.xml` | Global record rule mirroring the ACL | +| `fusion_login_audit/views/fusion_login_audit_views.xml` | Standalone list / form / kanban / search views + window action | +| `fusion_login_audit/views/res_users_views.xml` | Smart button + "Login Activity" tab on user form | +| `fusion_login_audit/views/res_config_settings_views.xml` | "Login Audit" section on Settings page | +| `fusion_login_audit/views/menus.xml` | Settings → Technical → Login Audit submenus | +| `fusion_login_audit/tests/__init__.py` | Imports test modules | +| `fusion_login_audit/tests/test_login_audit.py` | TransactionCase: capture, fields, crons, alerts | +| `fusion_login_audit/tests/test_security.py` | HttpCase: ACL + view visibility | +| `fusion_login_audit/static/description/icon.png` | Copied from `C:\Users\gsing\Downloads\fusion logs.png` | + +--- + +## Task 1: Module skeleton + install smoke test + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\__init__.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\models\__init__.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\static\description\icon.png` +- Copy from: `C:\Users\gsing\Downloads\fusion logs.png` + +- [ ] **Step 1: Create the directory tree** + +```powershell +$root = "K:\Github\Odoo-Modules\fusion_login_audit" +New-Item -ItemType Directory -Path "$root\models", "$root\data", "$root\security", "$root\views", "$root\tests", "$root\static\description" -Force | Out-Null +``` + +- [ ] **Step 2: Copy the icon** + +```powershell +Copy-Item "C:\Users\gsing\Downloads\fusion logs.png" "K:\Github\Odoo-Modules\fusion_login_audit\static\description\icon.png" +``` + +- [ ] **Step 3: Write `__manifest__.py`** + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +{ + 'name': 'Fusion Login Audit', + 'version': '19.0.1.0.0', + 'category': 'Tools', + 'summary': 'Durable login audit log with geo-enrichment, retention, and failure alerts.', + 'description': """ +Fusion Login Audit +================== + +Captures every password authentication event (success + failure) in a +dedicated, append-only audit table. Surfaces history on the user form +as a smart button + tab (admins only). Async-enriches IPs with country, +city, and reverse DNS. Emails Settings admins on consecutive-failure +bursts. Daily retention cron honours a configurable horizon. + """, + 'author': 'Nexa Systems Inc.', + 'website': 'https://nexasystems.ca', + 'license': 'OPL-1', + 'depends': ['base', 'mail'], + 'data': [], # data files added in later tasks + 'installable': True, + 'application': False, + 'auto_install': False, +} +``` + +- [ ] **Step 4: Write `__init__.py`** + +```python +# -*- coding: utf-8 -*- +from . import models +``` + +- [ ] **Step 5: Write `models/__init__.py`** + +```python +# -*- coding: utf-8 -*- +# Files added in later tasks +``` + +- [ ] **Step 6: Verify install succeeds (this is the smoke test)** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev -i fusion_login_audit --stop-after-init 2>&1 | tail -30 +``` + +Expected: last line ends with `odoo: Initiating shutdown` and no `ERROR`/`CRITICAL` lines above. The module is in the registry. + +Sanity check the install via psql: +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT name, state FROM ir_module_module WHERE name='fusion_login_audit';" +``` +Expected: one row, `state = installed`. + +- [ ] **Step 7: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): module skeleton + icon + +Empty installable module with manifest, package inits, and icon. +Subsequent tasks add the audit model, hooks, views, and tests. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 2: `fusion.login.audit` model + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\models\fusion_login_audit.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\__init__.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\tests\__init__.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__init__.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` (add the ACL — required for create from a test) +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\security\ir.model.access.csv` (full ACL deferred to Task 3 — this is the bare minimum to let the test create rows via sudo) + +- [ ] **Step 1: Write the failing test (`tests/test_login_audit.py`)** + +```python +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestFusionLoginAuditModel(TransactionCase): + + def test_model_exists_and_creates(self): + """Audit row can be created with all expected fields.""" + Audit = self.env['fusion.login.audit'].sudo() + rec = Audit.create({ + 'attempted_login': 'demo@example.com', + 'result': 'success', + 'ip_address': '203.0.113.5', + 'user_agent_raw': 'Mozilla/5.0 Test', + 'browser': 'Test 1.0', + 'os': 'TestOS', + 'device_type': 'desktop', + 'database': self.env.cr.dbname, + 'geo_lookup_state': 'pending', + }) + self.assertTrue(rec.id) + self.assertEqual(rec.result, 'success') + self.assertEqual(rec.geo_lookup_state, 'pending') + self.assertEqual(rec.database, self.env.cr.dbname) + self.assertTrue(rec.event_time) # default fires + + def test_failure_reason_optional(self): + """failure_reason is null on success rows.""" + rec = self.env['fusion.login.audit'].sudo().create({ + 'attempted_login': 'demo@example.com', + 'result': 'success', + }) + self.assertFalse(rec.failure_reason) + + def test_geo_state_internal_value(self): + """`internal` is an accepted geo_lookup_state value (distinct from private_ip).""" + rec = self.env['fusion.login.audit'].sudo().create({ + 'attempted_login': 'demo@example.com', + 'result': 'success', + 'geo_lookup_state': 'internal', + }) + self.assertEqual(rec.geo_lookup_state, 'internal') +``` + +- [ ] **Step 2: Wire test discovery in `tests/__init__.py`** + +```python +# -*- coding: utf-8 -*- +from . import test_login_audit +``` + +And update `K:\Github\Odoo-Modules\fusion_login_audit\__init__.py`: + +```python +# -*- coding: utf-8 -*- +from . import models +from . import tests +``` + +- [ ] **Step 3: Create a minimal ACL so the test can read its own writes** + +`K:\Github\Odoo-Modules\fusion_login_audit\security\ir.model.access.csv`: + +```csv +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fusion_login_audit_system,fusion.login.audit system,model_fusion_login_audit,base.group_system,1,0,0,0 +``` + +Add to `__manifest__.py` `data` key: + +```python + 'data': [ + 'security/ir.model.access.csv', + ], +``` + +- [ ] **Step 4: Run the test — expect it to FAIL with "model not found"** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(KeyError|TEST|FAIL|ERROR)" | head -20 +``` + +Expected: a `KeyError: 'fusion.login.audit'` or an Odoo ParseError on the CSV referencing an unknown model. + +- [ ] **Step 5: Implement the model** + +`K:\Github\Odoo-Modules\fusion_login_audit\models\fusion_login_audit.py`: + +```python +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class FusionLoginAudit(models.Model): + _name = 'fusion.login.audit' + _description = 'Login Audit Event' + _order = 'event_time desc, id desc' + _rec_name = 'attempted_login' + + user_id = fields.Many2one( + 'res.users', string='User', ondelete='set null', index=True, + help='Null when the attempted login did not match any user.', + ) + attempted_login = fields.Char( + string='Attempted Login', size=255, required=True, index=True, + ) + result = fields.Selection( + [('success', 'Success'), ('failure', 'Failure')], + string='Result', required=True, index=True, + ) + failure_reason = fields.Selection( + [ + ('bad_password', 'Bad password'), + ('unknown_user', 'Unknown user'), + ('disabled_user', 'Disabled user'), + ('2fa_failed', '2FA failed'), + ('other', 'Other'), + ], + string='Failure Reason', + ) + event_time = fields.Datetime( + string='Event Time', required=True, index=True, + default=fields.Datetime.now, + ) + ip_address = fields.Char(string='IP Address', size=45) + ip_hostname = fields.Char(string='Reverse DNS', size=255) + country_code = fields.Char(string='Country Code', size=2, index=True) + country_name = fields.Char(string='Country', size=64) + city = fields.Char(string='City', size=128) + geo_state = fields.Char(string='Region', size=64) + geo_lookup_state = fields.Selection( + [ + ('pending', 'Pending'), + ('done', 'Done'), + ('private_ip', 'Private IP'), + ('internal', 'Internal (no request)'), + ('failed', 'Lookup failed'), + ], + string='Geo Lookup State', default='pending', index=True, + ) + user_agent_raw = fields.Char(string='User Agent', size=512) + browser = fields.Char(string='Browser', size=64) + os = fields.Char(string='OS', size=64) + device_type = fields.Selection( + [ + ('desktop', 'Desktop'), + ('mobile', 'Mobile'), + ('tablet', 'Tablet'), + ('bot', 'Bot'), + ('unknown', 'Unknown'), + ], + string='Device Type', default='unknown', + ) + database = fields.Char(string='Database', size=64) + + _sql_constraints = [ + ( + 'result_failure_reason_consistent', + "CHECK ((result = 'success' AND failure_reason IS NULL) " + "OR (result = 'failure' AND failure_reason IS NOT NULL))", + 'A failure row must have a failure_reason; a success row must not.', + ), + ] + + def init(self): + """Create composite indexes that improve the three hot queries: + per-user history, failure-burst detection by login, geo cron worklist.""" + self.env.cr.execute(""" + CREATE INDEX IF NOT EXISTS fusion_login_audit_user_time_idx + ON fusion_login_audit (user_id, event_time DESC); + CREATE INDEX IF NOT EXISTS fusion_login_audit_login_time_idx + ON fusion_login_audit (attempted_login, event_time DESC); + CREATE INDEX IF NOT EXISTS fusion_login_audit_geo_state_idx + ON fusion_login_audit (geo_lookup_state, event_time); + """) +``` + +Wire it into `models/__init__.py`: + +```python +# -*- coding: utf-8 -*- +from . import fusion_login_audit +``` + +- [ ] **Step 6: Run the test — expect PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(TEST|FAIL|ERROR|OK)" | tail -20 +``` + +Expected: three test methods, `Ran 3 tests`, `OK`. Sanity-check the composite index: + +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "\d fusion_login_audit" | grep idx +``` + +Expected: `fusion_login_audit_user_time_idx`, `fusion_login_audit_login_time_idx`, `fusion_login_audit_geo_state_idx` all present. + +- [ ] **Step 7: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): add fusion.login.audit model + +- All 16 columns per spec (user, attempted_login, result, failure_reason, + event_time, ip/geo fields, user_agent triple, device_type, database). +- SQL check constraint binds failure_reason presence to result value. +- init() creates the three composite indexes (user+time, login+time, + geo_state+time) supporting per-user, failure-burst, and geo cron queries. +- Minimal admin-read ACL added so subsequent tests can verify writes. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 3: Security — record rule + final ACL + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\security\security.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` (add `security/security.xml` to `data`) +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\__init__.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_security.py` + +- [ ] **Step 1: Write the failing security tests** + +`K:\Github\Odoo-Modules\fusion_login_audit\tests\test_security.py`: + +```python +# -*- coding: utf-8 -*- +from odoo.exceptions import AccessError +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestFusionLoginAuditSecurity(TransactionCase): + + def setUp(self): + super().setUp() + self.audit_row = self.env['fusion.login.audit'].sudo().create({ + 'attempted_login': 'sec-test@example.com', + 'result': 'success', + 'database': self.env.cr.dbname, + }) + # Internal non-admin user (active employee, not a Settings admin) + self.regular_user = self.env['res.users'].sudo().create({ + 'name': 'Regular Tester', + 'login': 'regular-tester@example.com', + 'password': 'regular-tester-pw-1', + 'groups_id': [(6, 0, [self.env.ref('base.group_user').id])], + }) + + def test_settings_admin_can_read(self): + """Settings admins (base.group_system) can read audit rows.""" + admin = self.env.ref('base.user_admin') + rec = self.audit_row.with_user(admin).read(['attempted_login']) + self.assertEqual(rec[0]['attempted_login'], 'sec-test@example.com') + + def test_regular_user_cannot_read(self): + """A non-admin internal user cannot read audit rows.""" + with self.assertRaises(AccessError): + self.audit_row.with_user(self.regular_user).read(['attempted_login']) + + def test_nobody_can_write_via_orm(self): + """Even Settings admins cannot write via the ORM (audit is append-only).""" + admin = self.env.ref('base.user_admin') + with self.assertRaises(AccessError): + self.audit_row.with_user(admin).write({'attempted_login': 'tampered'}) + + def test_nobody_can_unlink_via_orm(self): + """Even Settings admins cannot delete via the ORM.""" + admin = self.env.ref('base.user_admin') + with self.assertRaises(AccessError): + self.audit_row.with_user(admin).unlink() +``` + +Wire it up — `tests/__init__.py`: + +```python +# -*- coding: utf-8 -*- +from . import test_login_audit +from . import test_security +``` + +- [ ] **Step 2: Run the tests — expect 2 to fail** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -20 +``` + +Expected: `test_settings_admin_can_read` passes (ACL already allows it), `test_regular_user_cannot_read` passes (ACL doesn't grant `base.group_user`), `test_nobody_can_write_via_orm` FAILS (ACL grants `perm_read=1` but the underlying `base.group_system` includes superuser privileges that bypass the ACL — we need a record rule), `test_nobody_can_unlink_via_orm` FAILS for the same reason. + +Actually — `base.user_admin` is in `base.group_system` and the ACL grants read only. Write/unlink should already fail with `AccessError`. Re-read the failure output carefully before changing course. If both write/unlink tests actually pass on the bare ACL, treat that as a green TDD result. + +- [ ] **Step 3: Add the record rule for defence-in-depth** + +`K:\Github\Odoo-Modules\fusion_login_audit\security\security.xml`: + +```xml + + + + + + fusion.login.audit: admin read only + + [(1, '=', 1)] + + + + + + + + + +``` + +Add to `__manifest__.py` `data` list (must come AFTER the ACL CSV): + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + ], +``` + +- [ ] **Step 4: Re-run — expect all 4 security tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 7 tests` (3 from Task 2 + 4 here), `OK`. + +- [ ] **Step 5: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): admin-only record rule + security tests + +Record rule restricts read to base.group_system. The ACL already +forbids write/create/unlink for every group (audit is append-only; +sudo() inside auth hooks is the only write path). Tests cover both +the positive (admin can read) and three negative (non-admin cannot +read; admin cannot write or unlink via ORM) paths. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 4: Capture helper — `_fc_build_event_vals` on `res.users` + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_users.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\__init__.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` (append) + +This task wires only the value-building helper, with no auth-path hooks yet. Building it independently means the next three tasks can each focus on a single path without re-inventing context extraction. + +- [ ] **Step 1: Read the Odoo reference before writing** + +```bash +docker exec odoo-modsdev-app cat /usr/lib/python3/dist-packages/odoo/addons/base/models/res_users.py | sed -n '1,40p' +docker exec odoo-modsdev-app python3 -c "from user_agents import parse; ua = parse('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/140.0 Safari/537.36'); print(ua.browser, ua.os, ua.is_pc)" +``` + +Expected on the second line: a `Browser(family='Chrome', major='140', minor='0', patch=None)`, `OS(family='Windows', major='10', …)`, and `True`. + +- [ ] **Step 2: Append failing tests to `test_login_audit.py`** + +Add inside the same `TestFusionLoginAuditModel` class: + +```python + def test_build_event_vals_with_no_request(self): + """Without a live request, geo_lookup_state is 'internal'.""" + ResUsers = self.env['res.users'] + vals = ResUsers._fc_build_event_vals( + result='success', + attempted_login='cron@example.com', + ) + self.assertEqual(vals['result'], 'success') + self.assertEqual(vals['attempted_login'], 'cron@example.com') + self.assertEqual(vals['ip_address'], 'internal') + self.assertEqual(vals['user_agent_raw'], '') + self.assertEqual(vals['geo_lookup_state'], 'internal') + self.assertEqual(vals['database'], self.env.cr.dbname) + + def test_build_event_vals_parses_user_agent(self): + """Parser fills browser/os/device_type from a stub UA dict.""" + ResUsers = self.env['res.users'] + vals = ResUsers._fc_build_event_vals( + result='success', + attempted_login='ua@example.com', + _override_ip='203.0.113.5', + _override_ua='Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 Chrome/140.0 Safari/537.36', + ) + self.assertEqual(vals['ip_address'], '203.0.113.5') + self.assertIn('Chrome', vals['browser']) + self.assertIn('Windows', vals['os']) + self.assertEqual(vals['device_type'], 'desktop') + self.assertEqual(vals['geo_lookup_state'], 'pending') + + def test_build_event_vals_strips_password(self): + """If a credential dict sneaks in, no password leaks into vals.""" + ResUsers = self.env['res.users'] + vals = ResUsers._fc_build_event_vals( + result='failure', + attempted_login='leak@example.com', + failure_reason='bad_password', + _credential={'login': 'leak@example.com', + 'password': 'super-secret-pw', + 'type': 'password'}, + ) + serialized = repr(vals) + self.assertNotIn('super-secret-pw', serialized) + self.assertEqual(vals['failure_reason'], 'bad_password') +``` + +- [ ] **Step 3: Run — expect FAIL ("AttributeError: ... _fc_build_event_vals")** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 3 new failures with `AttributeError: 'res.users' has no attribute '_fc_build_event_vals'`. + +- [ ] **Step 4: Implement the helper** + +`K:\Github\Odoo-Modules\fusion_login_audit\models\res_users.py`: + +```python +# -*- coding: utf-8 -*- +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class ResUsers(models.Model): + _inherit = 'res.users' + + # The credentials dict from auth flows may include 'password'. We never + # persist or log the password value. _SAFE_CRED_KEYS bounds the surface. + _SAFE_CRED_KEYS = ('login', 'type') + + @api.model + def _fc_build_event_vals( + self, + result, + attempted_login, + failure_reason=None, + user_id=None, + _override_ip=None, + _override_ua=None, + _credential=None, + ): + """Build the dict of values for a fusion.login.audit row. + + Pulls IP / User-Agent from the live HTTP request when available. + Falls back to ('internal', '') for XML-RPC / cron-initiated + auth, with geo_lookup_state='internal' so the geo cron skips them. + + The _override_* kwargs exist for tests so we don't have to fake a + full request. They are NOT a public API. + """ + from user_agents import parse as ua_parse + + vals = { + 'attempted_login': (attempted_login or '')[:255], + 'result': result, + 'failure_reason': failure_reason, + 'event_time': fields.Datetime.now(), + 'database': self.env.cr.dbname, + 'user_id': user_id, + } + + ip = _override_ip + ua_str = _override_ua + + if ip is None or ua_str is None: + try: + from odoo.http import request + if request and getattr(request, 'httprequest', None): + if ip is None: + ip = request.httprequest.remote_addr + if ua_str is None: + ua_str = request.httprequest.user_agent.string or '' + except Exception: + _logger.debug("fusion_login_audit: no request context", exc_info=True) + + if ip and ua_str is not None: + vals['ip_address'] = ip[:45] + vals['user_agent_raw'] = (ua_str or '')[:512] + ua = ua_parse(ua_str or '') + vals['browser'] = (f"{ua.browser.family} {ua.browser.version_string}".strip())[:64] + vals['os'] = (f"{ua.os.family} {ua.os.version_string}".strip())[:64] + if ua.is_bot: + vals['device_type'] = 'bot' + elif ua.is_mobile: + vals['device_type'] = 'mobile' + elif ua.is_tablet: + vals['device_type'] = 'tablet' + elif ua.is_pc: + vals['device_type'] = 'desktop' + else: + vals['device_type'] = 'unknown' + vals['geo_lookup_state'] = 'pending' + else: + vals['ip_address'] = 'internal' + vals['user_agent_raw'] = '' + vals['device_type'] = 'unknown' + vals['geo_lookup_state'] = 'internal' + + # Defensive: caller may pass _credential just so we can log its 'type'; + # the password itself must never reach vals. + if _credential is not None: + cred_type = _credential.get('type') + if cred_type: + vals.setdefault('_credential_type', cred_type) + # Never read _credential['password'] + + # Strip our internal-use scratch keys before returning. + vals.pop('_credential_type', None) + return vals +``` + +Wire into `models/__init__.py`: + +```python +# -*- coding: utf-8 -*- +from . import fusion_login_audit +from . import res_users +``` + +- [ ] **Step 5: Re-run — expect all 10 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 10 tests`, `OK`. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): add _fc_build_event_vals context helper + +Single helper builds vals for fusion.login.audit rows from the live +HTTP request (or falls back to ip='internal' + geo_lookup_state='internal' +when there is no request). Parses UA into browser/os/device_type via +the user_agents library. Never reads credential['password']. Tests +cover: no-request fallback, UA parsing on a Chrome/Windows UA, and +the regression that no password value leaks into the vals dict. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 5: Success path — `_update_last_login` override + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_users.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing tests** + +```python + def test_update_last_login_writes_audit_row(self): + """Calling _update_last_login on a user creates a success row.""" + user = self.env['res.users'].sudo().create({ + 'name': 'Audit Tester', + 'login': 'audit-tester@example.com', + 'password': 'audit-tester-pw-1', + }) + Audit = self.env['fusion.login.audit'].sudo() + before = Audit.search_count([('user_id', '=', user.id)]) + user._update_last_login() + after = Audit.search_count([('user_id', '=', user.id)]) + self.assertEqual(after, before + 1) + row = Audit.search([('user_id', '=', user.id)], + order='event_time desc', limit=1) + self.assertEqual(row.result, 'success') + self.assertEqual(row.attempted_login, user.login) + self.assertFalse(row.failure_reason) + self.assertEqual(row.database, self.env.cr.dbname) + + def test_audit_write_failure_does_not_block_login(self): + """An exception inside the audit write must not propagate.""" + from unittest.mock import patch + user = self.env['res.users'].sudo().create({ + 'name': 'Resilient Tester', + 'login': 'resilient-tester@example.com', + 'password': 'resilient-tester-pw-1', + }) + + def boom(self_, vals): + raise RuntimeError('simulated audit DB failure') + + with patch.object(type(self.env['fusion.login.audit']), + 'create', boom): + # Must not raise. + user._update_last_login() +``` + +- [ ] **Step 2: Run — expect FAIL ("user has no audit row")** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 2 new failures — `AssertionError: 0 != 1` (no row written) and the resilience test passes vacuously (no override exists yet so nothing can blow up). + +- [ ] **Step 3: Add the override + write helper** + +Append to `models/res_users.py`: + +```python + def _fc_record_login_event(self, result, failure_reason=None, + user_id=None, attempted_login=None, + _credential=None): + """Build vals + create the audit row via sudo. Never raises.""" + try: + vals = self._fc_build_event_vals( + result=result, + attempted_login=attempted_login + or (self.login if self else None) + or 'unknown', + failure_reason=failure_reason, + user_id=user_id or (self.id if self else None), + _credential=_credential, + ) + self.env['fusion.login.audit'].sudo().with_context( + mail_create_nolog=True + ).create(vals) + except Exception: + _logger.exception( + "fusion_login_audit: failed to record %s row for %s", + result, attempted_login or (self.login if self else 'unknown'), + ) + + def _update_last_login(self): + result = super()._update_last_login() + # Self is a singleton recordset of the user that just logged in. + self._fc_record_login_event(result='success') + return result +``` + +- [ ] **Step 4: Run — expect all 12 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 12 tests`, `OK`. + +- [ ] **Step 5: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): hook successful login via _update_last_login + +Overrides res.users._update_last_login to create a fusion.login.audit +row with result=success after the parent runs. The write goes through +sudo() + mail_create_nolog=True. Any exception in the audit path is +caught and logged but never propagates -- a broken audit table must +never block a real user from logging in. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 6: Known-user failure path — `_check_credentials` override + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_users.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Read the reference** + +```bash +docker exec odoo-modsdev-app grep -n "_check_credentials" /usr/lib/python3/dist-packages/odoo/addons/base/models/res_users.py | head -10 +docker exec odoo-modsdev-app sed -n '/def _check_credentials/,/^ def /p' /usr/lib/python3/dist-packages/odoo/addons/base/models/res_users.py | head -30 +``` + +Confirm the signature is `def _check_credentials(self, credential, env):` and that it raises `odoo.exceptions.AccessDenied` on bad password. + +- [ ] **Step 2: Append failing tests** + +```python + def test_bad_password_writes_failure_row(self): + """A wrong password creates a result=failure row with failure_reason='bad_password'.""" + from odoo.exceptions import AccessDenied + user = self.env['res.users'].sudo().create({ + 'name': 'Wrongpw Tester', + 'login': 'wrongpw-tester@example.com', + 'password': 'wrongpw-tester-pw-1', + }) + Audit = self.env['fusion.login.audit'].sudo() + before = Audit.search_count([('attempted_login', '=', user.login), + ('result', '=', 'failure')]) + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': 'definitely-wrong', + 'type': 'password'}, + {'interactive': False}, + ) + after = Audit.search_count([('attempted_login', '=', user.login), + ('result', '=', 'failure')]) + self.assertEqual(after, before + 1) + row = Audit.search([('attempted_login', '=', user.login), + ('result', '=', 'failure')], + order='event_time desc', limit=1) + self.assertEqual(row.failure_reason, 'bad_password') + self.assertEqual(row.user_id, user) + + def test_bad_password_never_appears_in_row(self): + """The attempted password string never lands in any field.""" + from odoo.exceptions import AccessDenied + secret = 'NeverInTheRow-9f3a82' + user = self.env['res.users'].sudo().create({ + 'name': 'Leak Test', + 'login': 'leak-test-2@example.com', + 'password': 'leak-test-pw-1', + }) + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': secret, 'type': 'password'}, + {'interactive': False}, + ) + row = self.env['fusion.login.audit'].sudo().search( + [('attempted_login', '=', user.login), + ('result', '=', 'failure')], + order='event_time desc', limit=1) + for fname in ('attempted_login', 'failure_reason', 'user_agent_raw', + 'browser', 'os', 'ip_address', 'ip_hostname', + 'city', 'country_name', 'country_code', 'geo_state'): + self.assertNotIn(secret, (row[fname] or ''), + f"Password leaked into field {fname}") +``` + +- [ ] **Step 3: Run — expect 2 FAILs** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 2 new `AssertionError: 0 != 1`. + +- [ ] **Step 4: Implement the override** + +Append to `models/res_users.py`: + +```python + def _check_credentials(self, credential, env): + from odoo.exceptions import AccessDenied + try: + return super()._check_credentials(credential, env) + except AccessDenied: + cred_type = (credential or {}).get('type', 'password') + reason = '2fa_failed' if cred_type == 'totp' else 'bad_password' + self._fc_record_login_event( + result='failure', + failure_reason=reason, + user_id=self.id, + attempted_login=(credential or {}).get('login') or self.login, + _credential=credential, + ) + raise +``` + +- [ ] **Step 5: Run — expect all 14 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 14 tests`, `OK`. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): hook bad-password failures via _check_credentials + +Wraps res.users._check_credentials. On AccessDenied, records a row with +result=failure and failure_reason='bad_password' (or '2fa_failed' when +credential['type'] == 'totp'), then re-raises. Regression test asserts +the attempted password value never lands in any audit field. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 7: Unknown-user failure path — `_login` override + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_users.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Read the reference** + +```bash +docker exec odoo-modsdev-app grep -n "def _login" /usr/lib/python3/dist-packages/odoo/addons/base/models/res_users.py | head -10 +docker exec odoo-modsdev-app sed -n '/def _login(/,/^ [a-z@]/p' /usr/lib/python3/dist-packages/odoo/addons/base/models/res_users.py | head -50 +``` + +Confirm `_login` is a `@classmethod` taking `(cls, db, credential, user_agent_env)` and raising `AccessDenied` when the login string doesn't resolve to a user. + +- [ ] **Step 2: Append failing test** + +```python + def test_unknown_user_writes_failure_row(self): + """A login attempt for a username that does not exist gets logged + with user_id=NULL and failure_reason='unknown_user'.""" + from odoo.exceptions import AccessDenied + bogus = 'this-user-does-not-exist@example.com' + Audit = self.env['fusion.login.audit'].sudo() + before = Audit.search_count([('attempted_login', '=', bogus)]) + with self.assertRaises(AccessDenied): + self.env['res.users']._login( + self.env.cr.dbname, + {'login': bogus, 'password': 'whatever', + 'type': 'password'}, + {'interactive': False}, + ) + after = Audit.search_count([('attempted_login', '=', bogus)]) + self.assertEqual(after, before + 1) + row = Audit.search([('attempted_login', '=', bogus)], + order='event_time desc', limit=1) + self.assertFalse(row.user_id) + self.assertEqual(row.failure_reason, 'unknown_user') + self.assertEqual(row.result, 'failure') +``` + +- [ ] **Step 3: Run — expect FAIL ("0 != 1")** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 1 new `AssertionError: 0 != 1`. + +- [ ] **Step 4: Implement the classmethod override** + +Append to `models/res_users.py`. Use `api.Environment.manage()` + a fresh cursor because the classmethod runs outside any per-user env: + +```python + @classmethod + def _login(cls, db, credential, user_agent_env): + from odoo.exceptions import AccessDenied + try: + return super()._login(db, credential, user_agent_env) + except AccessDenied: + try: + cls._fc_record_unknown_user_failure( + db, credential, user_agent_env, + ) + except Exception: + _logger.exception( + "fusion_login_audit: failed to record unknown-user " + "failure for db=%s login=%s", + db, (credential or {}).get('login'), + ) + raise + + @classmethod + def _fc_record_unknown_user_failure(cls, db, credential, user_agent_env): + """Insert a failure row from outside a per-user env. We open our + own short-lived cursor so we don't depend on caller transaction + semantics.""" + import odoo + from odoo import api, SUPERUSER_ID + registry = odoo.modules.registry.Registry(db) + with registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + ResUsers = env['res.users'] + vals = ResUsers._fc_build_event_vals( + result='failure', + attempted_login=(credential or {}).get('login') or 'unknown', + failure_reason='unknown_user', + _credential=credential, + ) + env['fusion.login.audit'].sudo().with_context( + mail_create_nolog=True + ).create(vals) + # explicit commit: caller is going to raise AccessDenied which + # might roll back its own transaction; ours is separate. + cr.commit() +``` + +- [ ] **Step 5: Run — expect all 15 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 15 tests`, `OK`. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): hook unknown-user failures via _login + +Overrides the res.users._login classmethod. When the login string does +not resolve to any user, super() raises AccessDenied; we open our own +short-lived cursor (because the auth flow runs outside any per-user +env), record a row with user_id=NULL and failure_reason='unknown_user', +commit it, then re-raise. This closes the gap where typo'd or scanned +logins would otherwise vanish from the audit trail. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 8: `res.users` computed fields + smart button + form view + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_users.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\views\res_users_views.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing tests** + +```python + def test_computed_last_successful_login(self): + """x_fc_last_successful_login reflects the latest success row.""" + user = self.env['res.users'].sudo().create({ + 'name': 'Compute Tester', + 'login': 'compute-tester@example.com', + 'password': 'compute-tester-pw-1', + }) + self.env['fusion.login.audit'].sudo().create({ + 'user_id': user.id, + 'attempted_login': user.login, + 'result': 'success', + 'database': self.env.cr.dbname, + }) + user.invalidate_recordset(['x_fc_last_successful_login', + 'x_fc_login_audit_count']) + self.assertTrue(user.x_fc_last_successful_login) + self.assertGreaterEqual(user.x_fc_login_audit_count, 1) + + def test_action_view_login_audit_returns_window_action(self): + """The smart-button action returns an act_window scoped to this user.""" + user = self.env['res.users'].sudo().create({ + 'name': 'Action Tester', + 'login': 'action-tester@example.com', + 'password': 'action-tester-pw-1', + }) + action = user.action_fc_view_login_audit() + self.assertEqual(action['res_model'], 'fusion.login.audit') + self.assertEqual(action['type'], 'ir.actions.act_window') + self.assertIn(('user_id', '=', user.id), action['domain']) +``` + +- [ ] **Step 2: Run — expect 2 FAILs** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `AttributeError: 'res.users' object has no attribute 'x_fc_last_successful_login'` and the action method missing. + +- [ ] **Step 3: Add fields + action method** + +Append to `models/res_users.py`: + +```python + x_fc_login_audit_ids = fields.One2many( + 'fusion.login.audit', 'user_id', + string='Login Activity', + ) + x_fc_login_audit_count = fields.Integer( + string='Login Audit Count', + compute='_compute_x_fc_login_audit_count', + ) + x_fc_last_successful_login = fields.Datetime( + string='Last Successful Login', + compute='_compute_x_fc_last_successful_login', + store=True, + ) + x_fc_last_login_ip = fields.Char( + string='Last Login IP', size=45, + compute='_compute_x_fc_last_successful_login', + store=True, + ) + + @api.depends('x_fc_login_audit_ids') + def _compute_x_fc_login_audit_count(self): + Audit = self.env['fusion.login.audit'].sudo() + groups = Audit.read_group( + domain=[('user_id', 'in', self.ids)], + fields=['user_id'], + groupby=['user_id'], + ) + counts = {g['user_id'][0]: g['user_id_count'] for g in groups} + for user in self: + user.x_fc_login_audit_count = counts.get(user.id, 0) + + @api.depends('x_fc_login_audit_ids.event_time', + 'x_fc_login_audit_ids.result', + 'x_fc_login_audit_ids.ip_address') + def _compute_x_fc_last_successful_login(self): + Audit = self.env['fusion.login.audit'].sudo() + for user in self: + row = Audit.search( + [('user_id', '=', user.id), ('result', '=', 'success')], + order='event_time desc', limit=1, + ) + user.x_fc_last_successful_login = row.event_time or False + user.x_fc_last_login_ip = row.ip_address or False + + def action_fc_view_login_audit(self): + self.ensure_one() + return { + 'name': _('Login Activity'), + 'type': 'ir.actions.act_window', + 'res_model': 'fusion.login.audit', + 'view_mode': 'list,form', + 'domain': [('user_id', '=', self.id)], + 'context': {'create': False, 'edit': False, 'delete': False, + 'default_user_id': self.id}, + } +``` + +Add `_` to imports at top of file: + +```python +from odoo import _, api, fields, models +``` + +- [ ] **Step 4: Add the form view (smart button + tab)** + +`K:\Github\Odoo-Modules\fusion_login_audit\views\res_users_views.xml`: + +```xml + + + + + res.users.form.inherit.fusion_login_audit + res.users + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +Add to `__manifest__.py` `data`: + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + 'views/res_users_views.xml', + ], +``` + +- [ ] **Step 5: Run — expect all 17 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 17 tests`, `OK`. Also confirm the inherited view loaded: + +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT name FROM ir_ui_view WHERE name='res.users.form.inherit.fusion_login_audit';" +``` + +Expected: 1 row. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): smart button + Login Activity tab on res.users + +Adds four x_fc_* fields on res.users: login_audit_ids (One2many), +login_audit_count (compute), last_successful_login (compute, stored), +last_login_ip (compute, stored). action_fc_view_login_audit returns +a window action scoped to the current user. View inheritance adds a +smart button to the button box and a "Login Activity" page to the +notebook, both gated by base.group_system. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 9: Standalone views + menus for `fusion.login.audit` + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\views\fusion_login_audit_views.xml` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\views\menus.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` + +This task has no Python logic — just XML. The "test" is install-time view validation: a broken view will fail the install. + +- [ ] **Step 1: Write the views** + +`K:\Github\Odoo-Modules\fusion_login_audit\views\fusion_login_audit_views.xml`: + +```xml + + + + + + fusion.login.audit.list + fusion.login.audit + + + + + + + + + + + + + + + + + + + + fusion.login.audit.form + fusion.login.audit + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + fusion.login.audit.search + fusion.login.audit + + + + + + + + + + + + + + + + + + + + + + + + + + Login Events + fusion.login.audit + list,form + + {} + + + + Failed Logins (24h) + fusion.login.audit + list,form + + {'search_default_filter_failure': 1, 'search_default_filter_24h': 1} + + +
+``` + +`K:\Github\Odoo-Modules\fusion_login_audit\views\menus.xml`: + +```xml + + + + + + + + + + +``` + +Add to `__manifest__.py` `data` list (order matters — views before menus): + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + 'views/fusion_login_audit_views.xml', + 'views/res_users_views.xml', + 'views/menus.xml', + ], +``` + +- [ ] **Step 2: Update install + test — expect all 17 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran|ParseError)" | tail -10 +``` + +Expected: `Ran 17 tests`, `OK`. If any `ParseError` appears, the XPath or field reference is wrong — fix and re-run. + +Sanity check the menu got registered: + +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT m.id, m.name FROM ir_ui_menu m JOIN ir_model_data d ON d.res_id=m.id AND d.model='ir.ui.menu' WHERE d.module='fusion_login_audit';" +``` + +Expected: 3 menus. + +- [ ] **Step 3: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): standalone views + menus + +List, form, and search views for fusion.login.audit, plus a "Login +Events" full-history action and a "Failed Logins (24h)" pre-filtered +action. Both surface under Settings -> Technical -> Login Audit +(menu items gated by base.group_system). Views are no-create / no-edit +/ no-delete to enforce append-only at the UI layer too. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 10: Settings model + Settings page section + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_config_settings.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\__init__.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\views\res_config_settings_views.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing test** + +```python + def test_settings_round_trip(self): + """Writing settings persists them via ir.config_parameter.""" + Settings = self.env['res.config.settings'].sudo() + Settings.create({ + 'x_fc_login_audit_retention_days': 90, + 'x_fc_login_audit_alert_threshold': 3, + 'x_fc_login_audit_alert_window_min': 5, + 'x_fc_login_audit_alert_enabled': False, + }).execute() + ICP = self.env['ir.config_parameter'].sudo() + self.assertEqual(ICP.get_param('fusion_login_audit.retention_days'), '90') + self.assertEqual(ICP.get_param('fusion_login_audit.alert_threshold'), '3') + self.assertEqual(ICP.get_param('fusion_login_audit.alert_window_min'), '5') + self.assertEqual(ICP.get_param('fusion_login_audit.alert_enabled'), 'False') +``` + +- [ ] **Step 2: Run — expect FAIL ("AttributeError" on x_fc_login_audit_retention_days)** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 1 new failure. + +- [ ] **Step 3: Write the settings model** + +`K:\Github\Odoo-Modules\fusion_login_audit\models\res_config_settings.py`: + +```python +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + x_fc_login_audit_retention_days = fields.Integer( + string='Login Audit Retention (days)', + default=365, + config_parameter='fusion_login_audit.retention_days', + help='Login audit rows older than this are deleted by the nightly ' + 'cron. Set to 0 to keep forever.', + ) + x_fc_login_audit_alert_threshold = fields.Integer( + string='Alert After N Consecutive Failures', + default=5, + config_parameter='fusion_login_audit.alert_threshold', + help='When this many failures for the same attempted login occur ' + 'within the alert window, Settings admins receive one email.', + ) + x_fc_login_audit_alert_window_min = fields.Integer( + string='Alert Window (minutes)', + default=15, + config_parameter='fusion_login_audit.alert_window_min', + ) + x_fc_login_audit_alert_enabled = fields.Boolean( + string='Send Failed-Login Alerts', + default=True, + config_parameter='fusion_login_audit.alert_enabled', + ) +``` + +Wire into `models/__init__.py`: + +```python +# -*- coding: utf-8 -*- +from . import fusion_login_audit +from . import res_users +from . import res_config_settings +``` + +- [ ] **Step 4: Write the settings view** + +`K:\Github\Odoo-Modules\fusion_login_audit\views\res_config_settings_views.xml`: + +```xml + + + + + res.config.settings.form.login.audit + res.config.settings + + + + + + + + + + + + + + + + + + + + + + +``` + +> If `//block[@id='userManagement']` doesn't exist in this Odoo 19 build, fall back to `` and wrap in a `...` directly. Verify by running the failing install once with the original XPath and reading the ParseError. + +Add to `__manifest__.py`: + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + 'views/fusion_login_audit_views.xml', + 'views/res_users_views.xml', + 'views/res_config_settings_views.xml', + 'views/menus.xml', + ], +``` + +- [ ] **Step 5: Run — expect all 18 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran|ParseError)" | tail -10 +``` + +Expected: `Ran 18 tests`, `OK`. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): settings model + page section + +Four x_fc_* fields on res.config.settings backed by ir.config_parameter: +retention_days (default 365, 0 = forever), alert_threshold (5), +alert_window_min (15), alert_enabled (True). New "Login Audit" block +under userManagement on the General Settings page (gated by +base.group_system). + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 11: Failure-burst alert (template + send logic + cooldown) + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\data\mail_template_data.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_users.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing tests** + +```python + def test_failure_burst_queues_one_email(self): + """5 failures in the alert window queue exactly one mail.mail.""" + from odoo.exceptions import AccessDenied + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param('fusion_login_audit.alert_threshold', '3') + ICP.set_param('fusion_login_audit.alert_window_min', '15') + ICP.set_param('fusion_login_audit.alert_enabled', 'True') + # Clear any cooldown left over from earlier tests. + ICP.set_param('fusion_login_audit.last_alert:burst@example.com', '') + + user = self.env['res.users'].sudo().create({ + 'name': 'Burst Tester', + 'login': 'burst@example.com', + 'password': 'burst-tester-pw-1', + }) + Mail = self.env['mail.mail'].sudo() + before = Mail.search_count([('subject', 'ilike', 'burst@example.com')]) + for _i in range(3): + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': 'wrong', + 'type': 'password'}, + {'interactive': False}, + ) + after = Mail.search_count([('subject', 'ilike', 'burst@example.com')]) + self.assertEqual(after, before + 1) + + def test_cooldown_suppresses_second_alert(self): + """A 4th and 5th failure within the cooldown queue zero more emails.""" + from odoo.exceptions import AccessDenied + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param('fusion_login_audit.alert_threshold', '3') + ICP.set_param('fusion_login_audit.alert_window_min', '15') + ICP.set_param('fusion_login_audit.alert_enabled', 'True') + ICP.set_param('fusion_login_audit.last_alert:cool@example.com', '') + + user = self.env['res.users'].sudo().create({ + 'name': 'Cooldown Tester', + 'login': 'cool@example.com', + 'password': 'cooldown-tester-pw-1', + }) + Mail = self.env['mail.mail'].sudo() + for _i in range(3): + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': 'wrong', + 'type': 'password'}, + {'interactive': False}, + ) + count_after_3 = Mail.search_count([('subject', 'ilike', 'cool@example.com')]) + for _i in range(2): + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': 'wrong', + 'type': 'password'}, + {'interactive': False}, + ) + count_after_5 = Mail.search_count([('subject', 'ilike', 'cool@example.com')]) + self.assertEqual(count_after_5, count_after_3, + "Cooldown should suppress additional emails") + + def test_alert_disabled_master_switch(self): + """alert_enabled=False suppresses all alerts regardless of threshold.""" + from odoo.exceptions import AccessDenied + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param('fusion_login_audit.alert_threshold', '1') + ICP.set_param('fusion_login_audit.alert_window_min', '15') + ICP.set_param('fusion_login_audit.alert_enabled', 'False') + ICP.set_param('fusion_login_audit.last_alert:disabled@example.com', '') + + user = self.env['res.users'].sudo().create({ + 'name': 'Disabled Tester', + 'login': 'disabled@example.com', + 'password': 'disabled-tester-pw-1', + }) + Mail = self.env['mail.mail'].sudo() + before = Mail.search_count([('subject', 'ilike', 'disabled@example.com')]) + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': 'wrong', + 'type': 'password'}, + {'interactive': False}, + ) + after = Mail.search_count([('subject', 'ilike', 'disabled@example.com')]) + self.assertEqual(after, before, "Disabled alerts should queue nothing") +``` + +- [ ] **Step 2: Run — expect 3 FAILs** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 3 new `AssertionError`s (no alert email queued yet). + +- [ ] **Step 3: Write the mail template** + +`K:\Github\Odoo-Modules\fusion_login_audit\data\mail_template_data.xml`: + +```xml + + + + + + Fusion Login Audit — Failure Burst Alert + + [Login Audit] Failed login attempts for {{ ctx.get('attempted_login') }} + +
+

The login audit detected + failed login attempt(s) + in the last minute(s) for + .

+

Most recent attempts:

+ + + + + + + + + + + + + + +
TimeIPCountryBrowserOS
+ + + + +
+

+ Sent by Fusion Login Audit. Tune the threshold and window in + Settings → General Settings → Login Audit. +

+
+
+
+ +
+
+``` + +- [ ] **Step 4: Add helpers + wire the call into `_check_credentials`** + +Append to `models/res_users.py` (before the `_check_credentials` override, or as new methods of the same class): + +```python + def _fc_alert_threshold(self): + ICP = self.env['ir.config_parameter'].sudo() + try: + return max(1, int(ICP.get_param( + 'fusion_login_audit.alert_threshold', 5))) + except (TypeError, ValueError): + return 5 + + def _fc_alert_window_min(self): + ICP = self.env['ir.config_parameter'].sudo() + try: + return max(1, int(ICP.get_param( + 'fusion_login_audit.alert_window_min', 15))) + except (TypeError, ValueError): + return 15 + + def _fc_alert_enabled(self): + ICP = self.env['ir.config_parameter'].sudo() + return ICP.get_param('fusion_login_audit.alert_enabled', 'True') == 'True' + + def _fc_recent_failure_count(self, attempted_login): + """Failures for this attempted_login within the alert window.""" + from datetime import timedelta + if not attempted_login: + return 0 + cutoff = fields.Datetime.now() - timedelta( + minutes=self._fc_alert_window_min()) + return self.env['fusion.login.audit'].sudo().search_count([ + ('attempted_login', '=', attempted_login), + ('result', '=', 'failure'), + ('event_time', '>=', cutoff), + ]) + + def _fc_send_failure_alert(self, attempted_login): + """Queue one alert mail unless cooldown is active. Cooldown is + 60 minutes, keyed by attempted_login, stored in ir.config_parameter.""" + from datetime import timedelta + if not self._fc_alert_enabled(): + return + if not attempted_login: + return + ICP = self.env['ir.config_parameter'].sudo() + cd_key = f'fusion_login_audit.last_alert:{attempted_login}' + cd_raw = ICP.get_param(cd_key) + now = fields.Datetime.now() + if cd_raw: + try: + last = fields.Datetime.from_string(cd_raw) + if last and (now - last) < timedelta(minutes=60): + return # cooldown active + except (TypeError, ValueError): + pass + + window = self._fc_alert_window_min() + cutoff = now - timedelta(minutes=window) + Audit = self.env['fusion.login.audit'].sudo() + rows = Audit.search([ + ('attempted_login', '=', attempted_login), + ('result', '=', 'failure'), + ('event_time', '>=', cutoff), + ], order='event_time desc', limit=20) + + admins = self.env.ref('base.group_system').users.filtered( + lambda u: u.email and not u.share) + if not admins: + return + + tmpl = self.env.ref( + 'fusion_login_audit.mail_template_failure_burst', + raise_if_not_found=False) + if not tmpl: + return + + ctx = { + 'attempted_login': attempted_login, + 'failure_count': len(rows), + 'window_min': window, + 'rows': [{ + 'event_time': fields.Datetime.to_string(r.event_time), + 'ip_address': r.ip_address or '', + 'country_code': r.country_code or '', + 'browser': r.browser or '', + 'os': r.os or '', + } for r in rows], + } + for admin in admins: + tmpl.with_context(ctx=ctx).send_mail( + admin.id, + email_values={'email_to': admin.email, + 'auto_delete': True}, + force_send=False, + ) + ICP.set_param(cd_key, fields.Datetime.to_string(now)) +``` + +Modify `_check_credentials` to call the alert after recording the failure: + +```python + def _check_credentials(self, credential, env): + from odoo.exceptions import AccessDenied + try: + return super()._check_credentials(credential, env) + except AccessDenied: + cred_type = (credential or {}).get('type', 'password') + reason = '2fa_failed' if cred_type == 'totp' else 'bad_password' + attempted_login = (credential or {}).get('login') or self.login + self._fc_record_login_event( + result='failure', + failure_reason=reason, + user_id=self.id, + attempted_login=attempted_login, + _credential=credential, + ) + try: + if self._fc_recent_failure_count(attempted_login) \ + >= self._fc_alert_threshold(): + self._fc_send_failure_alert(attempted_login) + except Exception: + _logger.exception( + "fusion_login_audit: failed to send failure alert") + raise +``` + +Add to `__manifest__.py` `data` (after security, before views): + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + 'data/mail_template_data.xml', + 'views/fusion_login_audit_views.xml', + 'views/res_users_views.xml', + 'views/res_config_settings_views.xml', + 'views/menus.xml', + ], +``` + +- [ ] **Step 5: Run — expect all 21 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 21 tests`, `OK`. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): failure-burst alert email + +Adds a mail.template + helpers (_fc_recent_failure_count, +_fc_send_failure_alert) and wires the call into _check_credentials so +that crossing the threshold queues exactly one mail.mail per attempted +login per 60-minute cooldown window. Master kill switch +x_fc_login_audit_alert_enabled honoured. Recipients are all +base.group_system members with a non-empty email. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 12: Retention GC cron + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\fusion_login_audit.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\data\ir_cron_data.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing tests** + +```python + def test_retention_gc_deletes_old_rows(self): + """The GC method deletes rows older than retention_days.""" + from datetime import timedelta + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param('fusion_login_audit.retention_days', '30') + + now = fields.Datetime.now() + Audit = self.env['fusion.login.audit'].sudo() + old = Audit.create({ + 'attempted_login': 'gc-old@example.com', + 'result': 'success', + 'event_time': now - timedelta(days=45), + }) + recent = Audit.create({ + 'attempted_login': 'gc-recent@example.com', + 'result': 'success', + 'event_time': now - timedelta(days=5), + }) + old_id, recent_id = old.id, recent.id + + Audit._fc_retention_gc() + + self.assertFalse(Audit.browse(old_id).exists(), + "Row older than retention_days should be gone") + self.assertTrue(Audit.browse(recent_id).exists(), + "Row inside retention_days should survive") + + def test_retention_zero_keeps_forever(self): + """retention_days=0 keeps all rows.""" + from datetime import timedelta + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param('fusion_login_audit.retention_days', '0') + + now = fields.Datetime.now() + Audit = self.env['fusion.login.audit'].sudo() + ancient = Audit.create({ + 'attempted_login': 'forever@example.com', + 'result': 'success', + 'event_time': now - timedelta(days=3650), + }) + ancient_id = ancient.id + + Audit._fc_retention_gc() + + self.assertTrue(Audit.browse(ancient_id).exists(), + "retention_days=0 must keep everything") +``` + +- [ ] **Step 2: Run — expect FAIL ("AttributeError: _fc_retention_gc")** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 2 new failures. + +- [ ] **Step 3: Add the GC method** + +Append to `models/fusion_login_audit.py` (inside the `FusionLoginAudit` class): + +```python + @api.model + def _fc_retention_gc(self): + """Delete rows older than fusion_login_audit.retention_days. Called + daily by ir.cron. retention_days=0 means keep forever.""" + from datetime import timedelta + ICP = self.env['ir.config_parameter'].sudo() + try: + days = int(ICP.get_param( + 'fusion_login_audit.retention_days', 365)) + except (TypeError, ValueError): + days = 365 + if days <= 0: + return 0 + cutoff = fields.Datetime.now() - timedelta(days=days) + old = self.sudo().search([('event_time', '<', cutoff)]) + count = len(old) + if old: + old.unlink() + return count +``` + +- [ ] **Step 4: Write the cron data file** + +`K:\Github\Odoo-Modules\fusion_login_audit\data\ir_cron_data.xml`: + +```xml + + + + + + Fusion Login Audit: Retention GC + + code + model._fc_retention_gc() + 1 + days + -1 + + + + + + +``` + +Add to `__manifest__.py` `data`: + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + 'data/mail_template_data.xml', + 'data/ir_cron_data.xml', + 'views/fusion_login_audit_views.xml', + 'views/res_users_views.xml', + 'views/res_config_settings_views.xml', + 'views/menus.xml', + ], +``` + +- [ ] **Step 5: Run — expect all 23 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 23 tests`, `OK`. Sanity-check the cron is registered: + +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT cron_name, interval_number, interval_type, active FROM ir_cron c JOIN ir_act_server a ON a.id=c.ir_actions_server_id WHERE a.name LIKE 'Fusion Login Audit%';" +``` + +Expected: 1 row, `interval_number=1`, `interval_type='days'`, `active=t`. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): nightly retention GC cron + +Adds _fc_retention_gc() that deletes rows older than the configured +horizon (default 365 days; 0 = keep forever). Registered as a daily +ir.cron firing at 03:00 next-day. Tests verify both the delete path +and the "keep forever" short-circuit. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 13: Async geo-enrichment cron + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\fusion_login_audit.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\data\ir_cron_data.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing tests** + +```python + def test_geo_private_ip_shortcut(self): + """Private IPs short-circuit to state='private_ip' without HTTP.""" + Audit = self.env['fusion.login.audit'].sudo() + rec = Audit.create({ + 'attempted_login': 'lan@example.com', + 'result': 'success', + 'ip_address': '192.168.1.40', + 'geo_lookup_state': 'pending', + }) + Audit._fc_geo_enrich_pending(limit=10) + rec.invalidate_recordset() + self.assertEqual(rec.geo_lookup_state, 'private_ip') + self.assertEqual(rec.country_code, '--') + + def test_geo_cache_hit_avoids_http(self): + """A second row with the same recent IP copies from cache.""" + from unittest.mock import patch + Audit = self.env['fusion.login.audit'].sudo() + # Seed a "done" row from the same IP. + Audit.create({ + 'attempted_login': 'seed@example.com', + 'result': 'success', + 'ip_address': '203.0.113.99', + 'geo_lookup_state': 'done', + 'country_code': 'CA', + 'country_name': 'Canada', + 'city': 'Toronto', + 'geo_state': 'Ontario', + }) + target = Audit.create({ + 'attempted_login': 'hit@example.com', + 'result': 'success', + 'ip_address': '203.0.113.99', + 'geo_lookup_state': 'pending', + }) + + with patch( + 'odoo.addons.fusion_login_audit.models.fusion_login_audit.requests.get' + ) as mock_get: + Audit._fc_geo_enrich_pending(limit=10) + mock_get.assert_not_called() + + target.invalidate_recordset() + self.assertEqual(target.geo_lookup_state, 'done') + self.assertEqual(target.country_code, 'CA') + self.assertEqual(target.city, 'Toronto') + + def test_geo_internal_skipped(self): + """Rows with geo_lookup_state='internal' are not picked up.""" + Audit = self.env['fusion.login.audit'].sudo() + rec = Audit.create({ + 'attempted_login': 'cron@example.com', + 'result': 'success', + 'ip_address': 'internal', + 'geo_lookup_state': 'internal', + }) + # Should be a no-op. + Audit._fc_geo_enrich_pending(limit=10) + rec.invalidate_recordset() + self.assertEqual(rec.geo_lookup_state, 'internal') +``` + +- [ ] **Step 2: Run — expect 3 FAILs** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 3 new `AttributeError` failures on `_fc_geo_enrich_pending`. + +- [ ] **Step 3: Implement the worker** + +Append to `models/fusion_login_audit.py` (top of file, add imports): + +```python +import ipaddress +import logging +import socket +from datetime import timedelta + +import requests + +_logger = logging.getLogger(__name__) +``` + +And inside the class: + +```python + _FC_PRIVATE_NETWORKS = ( + ipaddress.ip_network('10.0.0.0/8'), + ipaddress.ip_network('172.16.0.0/12'), + ipaddress.ip_network('192.168.0.0/16'), + ipaddress.ip_network('127.0.0.0/8'), + ipaddress.ip_network('::1/128'), + ipaddress.ip_network('fe80::/10'), + ) + + @api.model + def _fc_is_private_ip(self, ip): + if not ip or ip == 'internal': + return False # 'internal' is handled by its own state + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return False + return any(addr in net for net in self._FC_PRIVATE_NETWORKS) + + @api.model + def _fc_geo_cache_hit(self, ip): + """Return a dict of geo fields if we've resolved this IP in the last + 30 days, else None.""" + if not ip: + return None + cutoff = fields.Datetime.now() - timedelta(days=30) + cached = self.sudo().search([ + ('ip_address', '=', ip), + ('geo_lookup_state', '=', 'done'), + ('event_time', '>=', cutoff), + ], limit=1, order='event_time desc') + if not cached: + return None + return { + 'country_code': cached.country_code, + 'country_name': cached.country_name, + 'city': cached.city, + 'geo_state': cached.geo_state, + 'ip_hostname': cached.ip_hostname, + } + + @api.model + def _fc_geo_reverse_dns(self, ip): + try: + socket.setdefaulttimeout(1.5) + host, _aliases, _ips = socket.gethostbyaddr(ip) + return (host or '')[:255] + except (socket.herror, socket.gaierror, OSError): + return '' + finally: + socket.setdefaulttimeout(None) + + @api.model + def _fc_geo_http_lookup(self, ip): + """Call ip-api.com. Returns (vals_dict, rate_limited_bool). + Falls back to ({}, False) on any error.""" + try: + resp = requests.get( + 'http://ip-api.com/json/' + ip, + params={'fields': 'status,country,countryCode,regionName,city'}, + timeout=3, + headers={'User-Agent': 'Odoo-FusionLoginAudit/19.0'}, + ) + rate_limited = resp.headers.get('X-Rl', '') == '0' + if resp.status_code != 200: + return ({}, rate_limited) + data = resp.json() or {} + if data.get('status') != 'success': + return ({}, rate_limited) + return ({ + 'country_code': (data.get('countryCode') or '')[:2], + 'country_name': (data.get('country') or '')[:64], + 'geo_state': (data.get('regionName') or '')[:64], + 'city': (data.get('city') or '')[:128], + }, rate_limited) + except (requests.RequestException, ValueError): + _logger.warning("fusion_login_audit: geo lookup failed for %s", + ip, exc_info=True) + return ({}, False) + + @api.model + def _fc_geo_enrich_pending(self, limit=100): + """Cron worker: process up to `limit` pending rows.""" + pending = self.sudo().search( + [('geo_lookup_state', '=', 'pending')], + order='event_time asc', limit=limit, + ) + if not pending: + return 0 + processed = 0 + for row in pending: + ip = row.ip_address + try: + if self._fc_is_private_ip(ip): + row.write({ + 'geo_lookup_state': 'private_ip', + 'country_code': '--', + 'country_name': 'Private network', + 'city': 'Private network', + }) + self.env.cr.commit() + processed += 1 + continue + + cached = self._fc_geo_cache_hit(ip) + if cached: + cached['geo_lookup_state'] = 'done' + row.write(cached) + self.env.cr.commit() + processed += 1 + continue + + hostname = self._fc_geo_reverse_dns(ip) if ip and ip != 'internal' else '' + vals, rate_limited = self._fc_geo_http_lookup(ip) if ip and ip != 'internal' else ({}, False) + if vals: + vals['ip_hostname'] = hostname + vals['geo_lookup_state'] = 'done' + row.write(vals) + else: + row.write({ + 'geo_lookup_state': 'failed', + 'ip_hostname': hostname, + }) + self.env.cr.commit() + processed += 1 + if rate_limited: + _logger.info("fusion_login_audit: ip-api rate limit " + "hit, stopping batch early") + break + except Exception: + _logger.exception( + "fusion_login_audit: geo enrich failed for row %s", row.id) + self.env.cr.rollback() + return processed +``` + +- [ ] **Step 4: Register the cron in `ir_cron_data.xml`** + +Append inside ``: + +```xml + + Fusion Login Audit: Geo Enrichment + + code + model._fc_geo_enrich_pending(limit=100) + 5 + minutes + -1 + + 10 + +``` + +- [ ] **Step 5: Run — expect all 26 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 26 tests`, `OK`. Confirm both crons are present: + +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT a.name, c.interval_number, c.interval_type, c.active FROM ir_cron c JOIN ir_act_server a ON a.id=c.ir_actions_server_id WHERE a.name LIKE 'Fusion Login Audit%' ORDER BY a.name;" +``` + +Expected: 2 rows — "Fusion Login Audit: Geo Enrichment" (5 min) and "Fusion Login Audit: Retention GC" (1 day). + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): async geo enrichment cron + +5-min cron processes up to 100 pending rows per pass: private IPs +short-circuit to state=private_ip; same-IP cache (30 days) avoids +duplicate ip-api.com calls; reverse DNS via socket with 1.5s timeout; +HTTP lookup routed through network_logger automatically. Rate-limit +header X-Rl honoured — batch breaks early when ip-api returns 0. +Tests cover the three non-HTTP paths (private, cache hit, internal-skip) +without touching the network. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 14: View-visibility security test (HttpCase) + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_security.py` + +The smart button and Login Activity tab are gated by `groups="base.group_system"` at the view level. Verify a non-admin user does not see them in the rendered view. + +- [ ] **Step 1: Append failing test** + +```python + def test_view_hides_button_and_tab_for_non_admin(self): + """A regular user fields_view_get on res.users does not include the + x_fc_login_audit_* fields (they live behind groups=base.group_system).""" + ResUsers = self.env['res.users'] + view = ResUsers.with_user(self.regular_user).get_view( + view_id=self.env.ref('base.view_users_form').id, + view_type='form', + ) + arch = view['arch'] + self.assertNotIn('x_fc_login_audit_count', arch, + "Smart-button field must not leak into non-admin view") + self.assertNotIn('x_fc_login_audit_ids', arch, + "Login Activity tab must not leak into non-admin view") + + def test_view_shows_button_and_tab_for_admin(self): + """A Settings admin DOES see both nodes.""" + admin = self.env.ref('base.user_admin') + view = self.env['res.users'].with_user(admin).get_view( + view_id=self.env.ref('base.view_users_form').id, + view_type='form', + ) + arch = view['arch'] + self.assertIn('x_fc_login_audit_count', arch) + self.assertIn('x_fc_login_audit_ids', arch) +``` + +- [ ] **Step 2: Run — expect PASS already (the `groups="base.group_system"` attr is what makes this work)** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 28 tests`, `OK`. If either test fails, the `groups=` attribute on the smart-button ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + From 0513ea23a455faae2cbc8f512fda76a8a98fa4c8 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 26 May 2026 21:23:05 -0400 Subject: [PATCH 26/37] feat(fusion_login_audit): standalone views + menus List, form, and search views for fusion.login.audit, plus a "Login Events" full-history action and a "Failed Logins (24h)" pre-filtered action. Both surface under Settings -> Technical -> Login Audit (menu items gated by base.group_system). Views are no-create / no-edit / no-delete to enforce append-only at the UI layer too. Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_login_audit/__manifest__.py | 2 + .../views/fusion_login_audit_views.xml | 118 ++++++++++++++++++ fusion_login_audit/views/menus.xml | 24 ++++ 3 files changed, 144 insertions(+) create mode 100644 fusion_login_audit/views/fusion_login_audit_views.xml create mode 100644 fusion_login_audit/views/menus.xml diff --git a/fusion_login_audit/__manifest__.py b/fusion_login_audit/__manifest__.py index 154bce29..59ac5bf6 100644 --- a/fusion_login_audit/__manifest__.py +++ b/fusion_login_audit/__manifest__.py @@ -26,7 +26,9 @@ bursts. Daily retention cron honours a configurable horizon. 'data': [ 'security/ir.model.access.csv', 'security/security.xml', + 'views/fusion_login_audit_views.xml', 'views/res_users_views.xml', + 'views/menus.xml', ], 'installable': True, 'application': False, diff --git a/fusion_login_audit/views/fusion_login_audit_views.xml b/fusion_login_audit/views/fusion_login_audit_views.xml new file mode 100644 index 00000000..526e5b1a --- /dev/null +++ b/fusion_login_audit/views/fusion_login_audit_views.xml @@ -0,0 +1,118 @@ + + + + + + fusion.login.audit.list + fusion.login.audit + + + + + + + + + + + + + + + + + + + + fusion.login.audit.form + fusion.login.audit + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + fusion.login.audit.search + fusion.login.audit + + + + + + + + + + + + + + + + + + + + + + + + + + Login Events + fusion.login.audit + list,form + + {} + + + + Failed Logins (24h) + fusion.login.audit + list,form + + {'search_default_filter_failure': 1, 'search_default_filter_24h': 1} + + +
diff --git a/fusion_login_audit/views/menus.xml b/fusion_login_audit/views/menus.xml new file mode 100644 index 00000000..3c3a78fa --- /dev/null +++ b/fusion_login_audit/views/menus.xml @@ -0,0 +1,24 @@ + + + + + + + + + + From 6f6aa6e90ae2dbf33924a824bf15925e59e13c83 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 26 May 2026 21:27:13 -0400 Subject: [PATCH 27/37] feat(fusion_login_audit): settings model + page section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four x_fc_* fields on res.config.settings backed by ir.config_parameter: retention_days (default 365, 0 = forever), alert_threshold (5), alert_window_min (15), alert_enabled (True). New "Login Audit" block on the General Settings page (gated by base.group_system on the block, NOT on the inherited view record per CLAUDE.md rule #11). CLAUDE.md gotchas added during this task: #5 Boolean config_parameter fields don't round-trip "False" as a string — IrConfigParameter.set_param deletes the row on falsy. Test with assertFalse, never assertEqual(..., "False"). #6 ir.ui.view uses group_ids (Odoo 19 rename mirrored from res.users). Setting groups_id on an ir.ui.view record raises ValueError at install. (The XML attribute groups="..." on inner nodes is unrelated and still works.) Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 + fusion_login_audit/__manifest__.py | 3 +- fusion_login_audit/models/__init__.py | 1 + .../models/res_config_settings.py | 31 ++++++++++++++++ fusion_login_audit/tests/test_login_audit.py | 18 ++++++++++ .../views/res_config_settings_views.xml | 36 +++++++++++++++++++ 6 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 fusion_login_audit/models/res_config_settings.py create mode 100644 fusion_login_audit/views/res_config_settings_views.xml diff --git a/CLAUDE.md b/CLAUDE.md index 6f5c331a..3ef13c0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ 3. **Backend OWL**: Use standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. `static props = []` not `{}`. 4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated). 5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields. + **`config_parameter=` Boolean fields don't round-trip `False` as a string.** Odoo's `set_values()` calls `IrConfigParameter.set_param(key, value)`, and `set_param` deletes the row when `value` is falsy (False / None / empty). So writing `False` to a Boolean config field means the param no longer exists in `ir_config_parameter`; a subsequent `get_param(key)` returns the *default* (Python `False`), not `'False'`. Test like `self.assertFalse(ICP.get_param('...'))` — never `assertEqual(..., 'False')`. (Integer/Float/Char go through `repr(value)` / strip, so they DO persist as strings — `'90'`, `'0'`, etc.) Source: `odoo/addons/base/models/res_config.py::set_values` and `ir_config_parameter.py::set_param`. 6. **res.groups**: NO `users` field, NO `category_id` field. **res.users**: field was renamed `groups_id` → `group_ids` (also `all_group_ids` for implied). The plural form is gone; using `groups_id` raises `ValueError: Invalid field 'groups_id' in 'res.users'`. **`ir.ui.view`**: same rename — view-level visibility gating uses `group_ids`, not `groups_id`. A record like `` on an `ir.ui.view` raises `ValueError: Invalid field 'groups_id' in 'ir.ui.view'` at module install. (The XML *attribute* `groups="base.group_system"` on form elements like ``, ` - - -
- - -
- - -
-