Centralize billing for all NexaSystems services (NexaCloud, NexaDesk, NexaMaps, custom apps, memberships) on the Odoo 19 Enterprise instance, replacing Lago. The module adds only the metering + integration layer; native sale_subscription / account_accountant / payment_stripe do all the financial work (invoicing, HST, dunning, portal, credit notes, Stripe). Includes: - Design spec (docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md): 6 locked decisions, architecture, data model, usage engine, Lago-shaped API, webhook control loop, NexaCloud pilot, phased dual-run migration. - Module scaffold: 7 fusion.billing.* models (service, account.link, metric, charge, usage, webhook, reconciliation), bearer-auth API controller shell, security ACLs, README. Compiles on Odoo 19.0; engine/API bodies are stubs pending the implementation plan. - CLAUDE.md rule #15: no sale.subscription model in Odoo 19 — a subscription is a sale.order(is_subscription) + sale.subscription.plan (verified live). Task 0 verified: a single Stripe account is shared across NexaCloud and all Lago providers, so no Stripe account/card migration is required. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
115 lines
7.3 KiB
Markdown
115 lines
7.3 KiB
Markdown
# Odoo Modules — Claude Code Instructions
|
|
|
|
## Project
|
|
27 custom Odoo 19 modules for Fusion Central (Westin Healthcare + NEXA Systems).
|
|
|
|
## Critical Rules — Odoo 19
|
|
1. **NEVER code from memory** — Always read a reference file from Docker first:
|
|
```bash
|
|
docker exec odoo-dev-app cat /usr/lib/python3/dist-packages/odoo/addons/<module>/static/src/<path>
|
|
```
|
|
2. **Frontend JS**: Use `Interaction` class from `@web/public/interaction`, registered via `registry.category("public.interactions")`. NOT IIFE/DOMContentLoaded.
|
|
3. **Backend OWL**: Use standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. `static props = []` not `{}`.
|
|
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
|
|
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
|
6. **res.groups**: NO `users` field, NO `category_id` field.
|
|
7. **Search views**: NO `group expand="0"` syntax.
|
|
8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file.
|
|
|
|
15. **There is NO `sale.subscription` model in Odoo 19** (Enterprise `sale_subscription`). A subscription is a **`sale.order`** with `is_subscription=True`, `plan_id` → **`sale.subscription.plan`** (the recurrence), plus `subscription_state` / `next_invoice_date` / `recurring_monthly`. Any Many2one or relation that targets "a subscription" must point at `sale.order` (filter `domain=[('is_subscription','=',True)]`) — **not** `sale.subscription`, which does not exist and fails at install. The surviving `sale.subscription.*` records are only the plan + wizards/reports (`sale.subscription.plan`, `sale.subscription.report`, `sale.subscription.change.customer.wizard`, `sale.subscription.close.reason.wizard`). Verified on live `nexamain` (odoo-nexa, 19.0): `SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'`.
|
|
|
|
## Card Styling — Copy Odoo's Kanban Pattern
|
|
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
|
|
```css
|
|
background-color: white;
|
|
border: 1px solid #d8dadd;
|
|
```
|
|
For custom OWL dashboards / client actions use the same approach:
|
|
- Define a `_tokens.scss` partial with explicit hex values wrapped in a CSS custom property:
|
|
```scss
|
|
$fp-card: var(--fp-card-bg, #ffffff);
|
|
$fp-border: var(--fp-border-color, #d8dadd);
|
|
```
|
|
- Reference those tokens everywhere (never `var(--bs-border-color)` directly)
|
|
- Three-layer contrast: **page** (grayest) → **container/column** (mid) → **card** (brightest). That's what makes cards pop.
|
|
- Reference implementation: `fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss`.
|
|
|
|
## Dark Mode — Branch on `$o-webclient-color-scheme` at SCSS Compile Time
|
|
Odoo 19 does NOT flip dark mode via a runtime DOM class. It compiles TWO asset bundles:
|
|
- `web.assets_backend` — compiled with `$o-webclient-color-scheme: bright`
|
|
- `web.assets_web_dark` — compiled with `$o-webclient-color-scheme: dark` (dark variant primary variables loaded first)
|
|
|
|
Your SCSS file is compiled into BOTH bundles. To make the dark bundle have different colors, **branch at compile time** using the SCSS variable Odoo sets:
|
|
|
|
```scss
|
|
$o-webclient-color-scheme: bright !default;
|
|
|
|
$_my-page-hex: #f3f4f6;
|
|
$_my-card-hex: #ffffff;
|
|
|
|
@if $o-webclient-color-scheme == dark {
|
|
$_my-page-hex: #1a1d21 !global;
|
|
$_my-card-hex: #22262d !global;
|
|
}
|
|
|
|
$my-page: var(--my-page-bg, $_my-page-hex);
|
|
$my-card: var(--my-card-bg, $_my-card-hex);
|
|
```
|
|
|
|
**Do NOT use** `.o_dark_mode` class selectors, `[data-bs-theme="dark"]`, or `@media (prefers-color-scheme: dark)` — none of those fire reliably in Odoo 19. The user toggles dark mode via the user profile, which sets a `color_scheme` cookie and reloads the page; Odoo then serves the dark bundle. Your SCSS `@if` handles the rest at compile time.
|
|
|
|
Verify by inspecting the attachments — you should see two files with different URLs for the two bundles:
|
|
```python
|
|
env['ir.qweb']._get_asset_bundle('web.assets_backend').css() # light
|
|
env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css() # dark
|
|
```
|
|
|
|
## Asset Bundle Cache Busting
|
|
Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS changes but the hash doesn't update, the browser serves the old bundle. Fixes in order of escalation:
|
|
1. Bump the module `version` in `__manifest__.py`
|
|
2. `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';` then restart odoo
|
|
3. Call `env['ir.qweb']._get_asset_bundle('web.assets_backend').css()` in odoo-shell to force regeneration
|
|
4. Hard-refresh browser with cache clear (DevTools → right-click refresh → *Empty Cache and Hard Reload*); on mobile clear website data
|
|
|
|
## Naming
|
|
- New fields: `x_fc_*` prefix
|
|
- Legacy fields: `x_studio_*`
|
|
- Canadian English for all user-facing text
|
|
- Currency: `$` sign with Monetary fields + currency_id
|
|
|
|
## Cursor-Managed Modules
|
|
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
|
|
|
|
## Workflow
|
|
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
|
- Local URL: http://localhost:8069
|
|
- Test before deploying. Edit existing files — don't create unnecessary new ones.
|
|
|
|
## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab
|
|
When a Python action opens an attachment, route it through `fusion_pdf_preview` instead of returning `ir.actions.act_url` with `download=true` or `target=new`. The preview dialog gives operators preview + print + download in one place and writes an audit log; non-PDF attachments fall back to the legacy download path automatically.
|
|
|
|
The drop-in replacement is the new helper on `ir.attachment`:
|
|
```python
|
|
return att.action_fusion_preview(title='My Doc')
|
|
# vs. the old pattern:
|
|
# return {'type': 'ir.actions.act_url',
|
|
# 'url': '/web/content/%s?download=true' % att.id,
|
|
# 'target': 'new'}
|
|
```
|
|
|
|
The helper auto-detects mimetype: PDFs go to the dialog, everything else (ZPL, CSV, XML, images) stays on download. So a callsite that today serves CSV today and a PDF tomorrow doesn't need a code change — same call, different routing.
|
|
|
|
If you need to invoke the client action directly (rare — only when you don't have a recordset handy), the tag is `fusion_pdf_preview.open_attachment` and the params are `{attachment_id, title, model_name, record_ids, report_name}`. See `fusion_pdf_preview/static/src/js/open_attachment_action.js`.
|
|
|
|
Existing reports (`ir.actions.report` of type `qweb-pdf`) are intercepted automatically by `fusion_pdf_preview/static/src/js/pdf_preview.js`; the helper above is for the *other* pattern — attachments opened by custom buttons.
|
|
|
|
## Supabase Knowledge Base
|
|
Before starting unfamiliar work, check Supabase for context:
|
|
```bash
|
|
PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U postgres -d postgres
|
|
```
|
|
- `fusionapps.decisions` — past architecture decisions
|
|
- `fusionapps.issues` — known issues and fixes
|
|
- `fusionapps.code_snippets` — reference code
|
|
- `fusionapps.quick_commands` — deployment and admin commands
|