Compare commits

...

15 Commits

Author SHA1 Message Date
gsinghpal
d770c0c3a9 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) <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
a5db0fe71e feat(billing): usage-rating + webhook-dispatch crons
- 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.
2026-05-27 08:42:08 -04:00
gsinghpal
c44fd89ed1 feat(billing): wire HTTP controllers to API handlers 2026-05-27 08:42:08 -04:00
gsinghpal
6c395709cf feat(billing): outbound webhook engine (HMAC + retry/backoff)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
0754d0b101 feat(billing): subscription creation handler (sale.order is_subscription)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
2435096f32 feat(billing): inbound API handlers (customer/usage/catalog)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
25952cf226 feat(billing): period usage aggregation by metric function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
eb1ee85d24 feat(billing): idempotent usage ingestion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
1e34a67384 feat(billing): metered charge math (quota + overage)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
a1cfab6fe9 feat(billing): identity resolution external account -> partner 2026-05-27 08:42:08 -04:00
gsinghpal
a46e31e710 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 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
032b10752e 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) <noreply@anthropic.com>
2026-05-27 08:40:51 -04:00
gsinghpal
e7d63a3859 docs(billing): core engine implementation plan (TDD, 11 tasks) 2026-05-27 08:40:51 -04:00
gsinghpal
2b47bd8b10 feat(billing): design + scaffold fusion_centralize_billing
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) <noreply@anthropic.com>
2026-05-27 08:40:51 -04:00
gsinghpal
5764d439c3 changes 2026-05-26 19:17:57 -04:00
30 changed files with 2918 additions and 2 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -16,6 +16,8 @@
7. **Search views**: NO `group expand="0"` syntax. 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. 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 ## 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: 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 ```css
@@ -79,8 +81,15 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state - **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
## Workflow ## Workflow
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init` - Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
- Local URL: http://localhost:8069 - 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 /<module> \
-u <module> --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 <pkg> --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. - Test before deploying. Edit existing files — don't create unnecessary new ones.
## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab ## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab

File diff suppressed because it is too large Load Diff

View File

@@ -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`).

View File

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

View File

@@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

@@ -0,0 +1,55 @@
# -*- 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",
"data/ir_cron.xml",
],
"installable": True,
"application": False,
"auto_install": False,
}

View File

@@ -0,0 +1 @@
from . import api

View File

@@ -0,0 +1,95 @@
# -*- 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 json
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
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}/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)
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):
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)
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):
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)
result = service._api_create_subscription(payload)
if result.get("status") == "error":
return self._json(result, status=400)
return self._json(result)

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="cron_fc_rate_usage" model="ir.cron">
<field name="name">Fusion Billing: Rate usage before invoicing</field>
<field name="model_id" ref="model_fusion_billing_usage"/>
<field name="state">code</field>
<field name="code">model._cron_rate_open_periods()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="active">True</field>
</record>
<record id="cron_fc_dispatch_webhooks" model="ir.cron">
<field name="name">Fusion Billing: Dispatch outbound webhooks</field>
<field name="model_id" ref="model_fusion_billing_webhook"/>
<field name="state">code</field>
<field name="code">model._cron_dispatch()</field>
<field name="interval_number">2</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
</odoo>

View File

@@ -0,0 +1,8 @@
from . import service
from . import account_link
from . import metric
from . import charge
from . import usage
from . import webhook
from . import reconciliation
from . import sale_order

View File

@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import api, 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.",
)
@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,
})

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
import math
from odoo import api, 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)"),
("package", "Package"),
],
default="standard", required=True,
)
currency_id = fields.Many2one(
"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': 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))
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: 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)

View File

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

View File

@@ -0,0 +1,39 @@
# -*- 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", required=True,
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()

View File

@@ -0,0 +1,32 @@
# -*- 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.
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)
else:
self.env['sale.order.line'].create(
{'order_id': self.id, 'product_id': charge.product_id.id, **vals})
return amount

View File

@@ -0,0 +1,225 @@
# -*- coding: utf-8 -*-
# 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):
"""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)
@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.
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
@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)
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'}
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):
"""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()
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:
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}
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]}
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'.
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'}
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': plan_id,
'order_line': order_lines,
})
sub.action_confirm()
return {'status': 'ok', 'subscription_id': sub.id,
'subscription_state': sub.subscription_state}

View File

@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
from odoo import api, 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(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 (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)
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([
('subscription_id', '=', subscription.id),
('metric_id', '=', metric.id),
('idempotency_key', '=', idem),
], limit=1)
if existing:
existing.write({'quantity': quantity})
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 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', '=', 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 = 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 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_start', '<', 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)

View File

@@ -0,0 +1,113 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
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):
"""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()
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"),
("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()
@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):
# 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(),
})
@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:
# 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-Id': str(wh.id)},
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'
# Cap the exponential backoff so the interval can't overflow.
wh.next_retry_at = now + timedelta(minutes=2 ** min(wh.attempts, 10))

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_billing_service_admin fusion.billing.service admin model_fusion_billing_service base.group_system 1 1 1 1
3 access_fusion_billing_account_link_admin fusion.billing.account.link admin model_fusion_billing_account_link base.group_system 1 1 1 1
4 access_fusion_billing_metric_admin fusion.billing.metric admin model_fusion_billing_metric base.group_system 1 1 1 1
5 access_fusion_billing_charge_admin fusion.billing.charge admin model_fusion_billing_charge base.group_system 1 1 1 1
6 access_fusion_billing_usage_admin fusion.billing.usage admin model_fusion_billing_usage base.group_system 1 1 1 1
7 access_fusion_billing_webhook_admin fusion.billing.webhook admin model_fusion_billing_webhook base.group_system 1 1 1 1
8 access_fusion_billing_reconciliation_admin fusion.billing.reconciliation admin model_fusion_billing_reconciliation base.group_system 1 1 1 1
9 access_fusion_billing_metric_acct fusion.billing.metric accountant model_fusion_billing_metric account.group_account_manager 1 1 1 0
10 access_fusion_billing_charge_acct fusion.billing.charge accountant model_fusion_billing_charge account.group_account_manager 1 1 1 0
11 access_fusion_billing_reconciliation_acct fusion.billing.reconciliation accountant model_fusion_billing_reconciliation account.group_account_manager 1 1 1 0

View File

@@ -0,0 +1,5 @@
from . import test_identity
from . import test_charge
from . import test_usage
from . import test_api
from . import test_webhook

View File

@@ -0,0 +1,139 @@
# -*- 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)
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')
# ── 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)]))

View File

@@ -0,0 +1,74 @@
# -*- 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')
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)
# ── 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)

View File

@@ -0,0 +1,55 @@
# -*- 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))
@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

View File

@@ -0,0 +1,171 @@
# -*- coding: utf-8 -*-
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):
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
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)
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)
# ── 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)

View File

@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
import hashlib
import hmac
import json
from unittest.mock import patch
from odoo.exceptions import ValidationError
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')
# ── 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)

BIN
fusion_plating/.DS_Store vendored Normal file

Binary file not shown.

BIN
fusion_plating/docs/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

View File

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