Compare commits
15 Commits
5f372b462a
...
d770c0c3a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d770c0c3a9 | ||
|
|
a5db0fe71e | ||
|
|
c44fd89ed1 | ||
|
|
6c395709cf | ||
|
|
0754d0b101 | ||
|
|
2435096f32 | ||
|
|
25952cf226 | ||
|
|
eb1ee85d24 | ||
|
|
1e34a67384 | ||
|
|
a1cfab6fe9 | ||
|
|
a46e31e710 | ||
|
|
032b10752e | ||
|
|
e7d63a3859 | ||
|
|
2b47bd8b10 | ||
|
|
5764d439c3 |
13
CLAUDE.md
13
CLAUDE.md
@@ -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
|
||||||
|
|||||||
1104
docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md
Normal file
1104
docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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`).
|
||||||
70
fusion_centralize_billing/README.md
Normal file
70
fusion_centralize_billing/README.md
Normal 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.
|
||||||
2
fusion_centralize_billing/__init__.py
Normal file
2
fusion_centralize_billing/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
55
fusion_centralize_billing/__manifest__.py
Normal file
55
fusion_centralize_billing/__manifest__.py
Normal 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,
|
||||||
|
}
|
||||||
1
fusion_centralize_billing/controllers/__init__.py
Normal file
1
fusion_centralize_billing/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import api
|
||||||
95
fusion_centralize_billing/controllers/api.py
Normal file
95
fusion_centralize_billing/controllers/api.py
Normal 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)
|
||||||
22
fusion_centralize_billing/data/ir_cron.xml
Normal file
22
fusion_centralize_billing/data/ir_cron.xml
Normal 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>
|
||||||
8
fusion_centralize_billing/models/__init__.py
Normal file
8
fusion_centralize_billing/models/__init__.py
Normal 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
|
||||||
57
fusion_centralize_billing/models/account_link.py
Normal file
57
fusion_centralize_billing/models/account_link.py
Normal 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,
|
||||||
|
})
|
||||||
80
fusion_centralize_billing/models/charge.py
Normal file
80
fusion_centralize_billing/models/charge.py
Normal 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)
|
||||||
32
fusion_centralize_billing/models/metric.py
Normal file
32
fusion_centralize_billing/models/metric.py
Normal 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.")
|
||||||
39
fusion_centralize_billing/models/reconciliation.py
Normal file
39
fusion_centralize_billing/models/reconciliation.py
Normal 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()
|
||||||
32
fusion_centralize_billing/models/sale_order.py
Normal file
32
fusion_centralize_billing/models/sale_order.py
Normal 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
|
||||||
225
fusion_centralize_billing/models/service.py
Normal file
225
fusion_centralize_billing/models/service.py
Normal 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}
|
||||||
120
fusion_centralize_billing/models/usage.py
Normal file
120
fusion_centralize_billing/models/usage.py
Normal 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)
|
||||||
113
fusion_centralize_billing/models/webhook.py
Normal file
113
fusion_centralize_billing/models/webhook.py
Normal 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))
|
||||||
11
fusion_centralize_billing/security/ir.model.access.csv
Normal file
11
fusion_centralize_billing/security/ir.model.access.csv
Normal 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
|
||||||
|
5
fusion_centralize_billing/tests/__init__.py
Normal file
5
fusion_centralize_billing/tests/__init__.py
Normal 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
|
||||||
139
fusion_centralize_billing/tests/test_api.py
Normal file
139
fusion_centralize_billing/tests/test_api.py
Normal 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)]))
|
||||||
74
fusion_centralize_billing/tests/test_charge.py
Normal file
74
fusion_centralize_billing/tests/test_charge.py
Normal 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)
|
||||||
55
fusion_centralize_billing/tests/test_identity.py
Normal file
55
fusion_centralize_billing/tests/test_identity.py
Normal 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
|
||||||
171
fusion_centralize_billing/tests/test_usage.py
Normal file
171
fusion_centralize_billing/tests/test_usage.py
Normal 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)
|
||||||
99
fusion_centralize_billing/tests/test_webhook.py
Normal file
99
fusion_centralize_billing/tests/test_webhook.py
Normal 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
BIN
fusion_plating/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
fusion_plating/docs/.DS_Store
vendored
Normal file
BIN
fusion_plating/docs/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
fusion_plating/docs/superpowers/.DS_Store
vendored
Normal file
BIN
fusion_plating/docs/superpowers/.DS_Store
vendored
Normal file
Binary file not shown.
27
scripts/fcb_test_on_trial.sh
Normal file
27
scripts/fcb_test_on_trial.sh
Normal 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'
|
||||||
Reference in New Issue
Block a user