This commit is contained in:
gsinghpal
2026-04-29 03:35:33 -04:00
parent 6ac6d24da6
commit a2fe1fcbcc
61 changed files with 4655 additions and 667 deletions

626
fusion_clover/CLAUDE.md Normal file
View File

@@ -0,0 +1,626 @@
# fusion_clover — Claude Code Instructions
## Purpose
Odoo 19 payment provider integration for **Clover** — Westin Healthcare's
new processor (running alongside `fusion_poynt` for Poynt which is
already deployed on `odoo-westin`).
Built by **Nexa Systems Inc** on a Clover developer account. Designed
for in-store **terminal** payments via Cloud REST Pay Display, plus
**ecommerce/portal** card-not-present payments and **manual** card
collection from the back office.
**As of 2026-04-29 v19.0.1.10.0**: end-to-end OAuth flow validated in
sandbox with Test Merc 2 (`ASVPRFJ5D5GF1`); $1 + $2 charges processed
and refunded successfully; battle-tested for declines, idempotency,
HMAC forgery, and recursive 401 prevention; Cloudflare Worker
dispatcher hammer-tested at 350 req/s. Ready for production cutover —
just needs prod App ID/Secret/RAID swap.
## Sister module
`../fusion_poynt/` is the same architecture targeted at Poynt. Use it
as the reference implementation for anything ambiguous here (it is
already production-tested at Westin). When in doubt about wizard UX,
account.move buttons, refund flow, surcharge logic, copy from
fusion_poynt — they share the same idiomatic patterns intentionally.
The Supabase decision log records:
> "fusion_clover: Full feature parity with fusion_poynt — Implemented
> void support, pre-refund verification, transaction age tracking with
> 180-day limit, non-referenced credits via POST /v1/credits and
> terminal, default terminal, terminal renaming, order ID tracking,
> extended webhooks (8 types)."
## Module layout
```
fusion_clover/
├── __manifest__.py # depends: payment, account_payment, sale
├── const.py # API URLs (v2 OAuth), status maps
├── utils.py # idempotency, payload builders, base64url
├── controllers/
│ ├── main.py # /payment/clover/{return,webhook,oauth/callback,
│ │ # terminals,send_to_terminal,terminal_status,
│ │ # terminal/callback,process_card}
│ └── portal.py # CustomerPortal override for auto payment_amount
├── models/
│ ├── payment_provider.py # PaymentProvider — credentials, ecom/platform/
│ │ # terminal request helpers w/ auto-refresh,
│ │ # charge/refund/credit/capture, OAuth helpers,
│ │ # server-side tokenization, brand detection
│ ├── payment_transaction.py # PaymentTransaction — token flow, refund/capture/
│ │ # void, action_clover_void, _apply_updates
│ ├── payment_token.py # clover_source_token char field
│ ├── clover_terminal.py # CloverTerminal — ping, send_payment, refund,
│ │ # check_status, display_welcome
│ ├── account_move.py # invoice/credit-note buttons, refund smart button
│ ├── sale_order.py # action_clover_collect_payment from SO
│ └── res_config_settings.py # surcharge config + open_clover_provider button
├── wizard/
│ ├── clover_payment_wizard.py # back-office collection (terminal | manual card)
│ └── clover_refund_wizard.py # referenced + non-referenced refund flow
├── views/ # XML for all of the above + payment provider form
├── data/
│ ├── payment_provider_data.xml # disabled-by-default provider record
│ ├── clover_surcharge_product.xml # CC processing fee product
│ └── clover_receipt_email_template.xml
├── report/
│ ├── clover_receipt_report.xml # ir.actions.report
│ └── clover_receipt_templates.xml # QWeb (incl. 2-page refund+original)
├── security/
│ ├── security.xml # Fusion Clover privilege + User/Admin groups
│ └── ir.model.access.csv
└── static/
└── src/interactions/payment_form.js # PaymentForm patch w/ Clover.js
# iframe SDK tokenization
```
## Clover API surfaces used
| Surface | Sandbox URL | Production URL | Used for |
|---|---|---|---|
| **OAuth v2 authorize** | `sandbox.dev.clover.com/oauth/v2/authorize` | `www.clover.com/oauth/v2/authorize` | Merchant authorization (NOT on apisandbox host) |
| **OAuth v2 token** | `apisandbox.dev.clover.com/oauth/v2/token` | `api.clover.com/oauth/v2/token` | Code → access_token + refresh_token |
| **OAuth v2 refresh** | `apisandbox.dev.clover.com/oauth/v2/refresh` | `api.clover.com/oauth/v2/refresh` | Renew access_token (single-use refresh!) |
| **Ecommerce API** | `scl-sandbox.dev.clover.com` | `scl.clover.com` | `/v1/charges`, `/v1/refunds`, `/v1/credits`, `/pakms/apikey` |
| **Tokenization Service** | `token-sandbox.dev.clover.com` | `token.clover.com` | `/v1/tokens` (server-side card → `clv_xxx`) |
| **Platform API v3** | `apisandbox.dev.clover.com` | `api.clover.com` | `/v3/merchants/{mId}`, `/v3/merchants/{mId}/devices` |
| **REST Pay Display Cloud** | `apisandbox.dev.clover.com/connect/v1` | `api.clover.com/connect/v1` | Terminal payments (`/payments`, `/device/ping`) |
Production base URLs are auto-selected when `payment.provider.state`
is `enabled` (vs. `test`). Constants live in `const.py`.
## God-nodes / cross-cutting components
(From the graphify report — most-connected abstractions)
1. `CloverController` — 24 edges (controllers/main.py)
2. `PaymentTransaction` — 19 edges
3. `PaymentProvider` — 17 edges
4. `CloverPaymentWizard` — 15 edges
5. `CloverRefundWizard` — 11 edges
6. `format_clover_amount()` (utils) — 10 edges
7. `AccountMove` — 9 edges
8. `CloverTerminal` — 8 edges
---
# Multi-tenant OAuth — Nexa Dispatcher
`fusion_clover` is designed to serve **many customer Odoo instances**
from a **single Clover developer app** owned by Nexa Systems Inc.
This is achieved with a stateless Cloudflare Worker that fans out
the OAuth callback AND inbound webhooks.
## Components
| Component | Where | Purpose |
|---|---|---|
| Clover Dev App "Fusion Clover Connector" | Nexa's Clover Global Developer Dashboard, App ID `2965A1TH3KG32`, RAID `B2EQP7PKGPYY8.2965A1TH3KG32` | Owns App ID + Secret + RAID. Site URL points at the dispatcher (NOT any one customer) |
| `nexa-clover-oauth-dispatcher` | Cloudflare Worker on `oauth.nexasystems.ca/*`, Account `6641e0c28475e4e9ddd32875f61da72e`, zone `067f715006cf8cca09d786513c38affa` | OAuth + webhook + launch fan-out. Stateless. Source at `K:/Github/nexa-oauth-dispatcher/` |
| `DISPATCHER_SECRET` | Cloudflare Workers Secret + every customer Odoo's `ir.config_parameter` `fusion_clover.dispatcher_secret` | HMAC-SHA256 key. The trust anchor of the whole flow |
| `ALLOWED_REDIRECT_HOSTS` | Worker env var in `wrangler.jsonc` | Belt-and-braces allow-list of customer hostnames the Worker is willing to redirect to |
| `MERCHANT_ROUTING_JSON` | Worker env var | `{merchantId: customerWebhookUrl}` map for webhook fan-out |
| `MERCHANT_ODOO_BASE_JSON` | Worker env var | `{merchantId: customerOdooBaseUrl}` for `/clover/launch` to build per-customer signed states |
| `CLOVER_APP_ID` | Worker env var | Used by `/clover/launch` to build `/oauth/v2/authorize` URL |
## Worker endpoints
| Endpoint | Method | Purpose |
|---|---|---|
| `/clover/callback` | GET | OAuth fan-out: HMAC-verify `state`, 302 to customer's Odoo `/payment/clover/oauth/callback` |
| `/clover/webhook` | POST | Webhook fan-out: route by `merchantId` from body (firehose or single-event format) |
| `/clover/webhook` | GET | 200 OK for Clover liveness pings |
| `/clover/launch` | GET | **Alternate Launch Path**: when Clover hits this with `?merchant_id=X`, build a fresh signed state and 302 to `/oauth/v2/authorize` |
| `/healthz` | GET | Liveness probe |
## Onboarding a new customer Odoo (~5 min, no Clover dashboard touch)
1. Add hostname to `ALLOWED_REDIRECT_HOSTS` AND merchant to
`MERCHANT_ROUTING_JSON` + `MERCHANT_ODOO_BASE_JSON` in
`wrangler.jsonc`, then `cd K:/Github/nexa-oauth-dispatcher && npm run deploy`.
2. On the customer's Odoo, set:
```sql
INSERT INTO ir_config_parameter (key, value, create_uid, create_date, write_uid, write_date)
VALUES
('fusion_clover.dispatcher_secret', '<same value as Cloudflare Worker secret>', 1, NOW(), 1, NOW()),
('fusion_clover.dispatcher_url', 'https://oauth.nexasystems.ca/clover/callback', 1, NOW(), 1, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
```
3. On the customer's Clover payment provider record, paste:
- **App ID** (Nexa's `clover_app_id`)
- **App Secret** (Nexa's `clover_app_secret`)
- **Remote App ID** (Nexa's `clover_remote_app_id`)
4. Click **Connect to Clover**. Merchant logs in to Clover, authorises,
token gets stored in `clover_oauth_access_token`.
No Clover dev-dashboard changes are ever required for new customers.
## Signed state format
```
<base64url(payload_json)>.<base64url(hmac_sha256(secret, payload_b64u))>
payload_json = {
"redirect_to": "https://erp.<customer>.ca/payment/clover/oauth/callback",
"nonce": "<32 hex chars>",
"iat": <unix seconds>,
"customer": "<optional human label>"
}
```
State expires after 1 hour (`iat` check). The Worker verifies HMAC
with constant-time comparison, checks `iat` freshness, validates
`redirect_to` is an allow-listed host, and 302-redirects with all
original Clover query params forwarded. The customer's Odoo callback
(`clover_oauth_callback`) re-verifies the HMAC as defence in depth
before exchanging the OAuth code for a token.
## Webhook fan-out
The Worker reads `merchantId` from either:
- `body.merchants.<mId>` (multi-merchant firehose format, e.g. payment object change events)
- `body.merchantId` (Hosted Checkout / Ecommerce single-event format)
It looks up the routing in `MERCHANT_ROUTING_JSON` and forwards the
**raw POST body verbatim** with the original `X-Clover-Auth-Code`
header preserved. The customer Odoo re-verifies the HMAC signature
against its own copy of the App Secret. **The Worker is a dumb pipe
for webhooks — it does NOT need to know the App Secret.**
## Webhook verification challenge
When you click "Send Verification Code" in the Clover dev dashboard,
Clover POSTs `{"verificationCode": "<uuid>"}` to the webhook URL. You
must paste this code back into Clover's UI to activate the webhook.
The verification code is logged in TWO places:
1. `wrangler tail nexa-clover-oauth-dispatcher` — Worker logs it
prominently to the Cloudflare console with a banner.
2. `docker logs odoo-dev-app | grep -i 'VERIFICATION CODE'` — the
Worker also forwards the verification POST to all routed Odoos so
the `controllers/main.py::clover_webhook` handler can log it too
(handler at `WARNING` level so it's hard to miss).
Either source works — pick whichever you have terminal access to.
## Auth precedence on outgoing Clover API calls
`_clover_get_platform_token()` picks in order:
1. `clover_oauth_access_token` (preferred — refreshable, app-scoped, works for Platform API + REST Pay Display + Ecommerce)
2. `clover_rest_api_token` (legacy single-merchant fallback from Clover Dashboard > Setup > API Tokens)
3. `clover_api_key` (Ecommerce private token — works only for Platform GET `/v3/merchants/{mId}`, will 401 on REST Pay Display)
`X-POS-Id` on REST Pay Display calls is `clover_remote_app_id` (RAID),
falling back to the static string `'FusionCloverOdoo'` for sandbox/dev.
`_clover_make_ecom_request` also routes through
`_clover_get_platform_token()` so the same precedence + auto-refresh
applies to Ecommerce API calls.
## OAuth token lifecycle
Three layers of token freshness protection, all in
`models/payment_provider.py`:
```python
# Layer 1 PROACTIVE: refresh if within 60s of expiry
def _clover_get_platform_token(self):
if self.clover_oauth_access_token:
self._clover_refresh_oauth_if_needed()
return self.clover_oauth_access_token
# Layer 2 REACTIVE: retry once on 401 with fresh token
def _clover_make_*_request(self, ..., _retry=True):
...
if response.status_code == 401 and _retry and self.clover_oauth_refresh_token:
if self._clover_refresh_oauth_token():
return self._clover_make_*_request(..., _retry=False)
# _retry=False on the recursive call PREVENTS infinite loops
# if the refreshed token also returns 401.
# Layer 3 ROTATION-SAFE: always store both new tokens together
def _clover_refresh_oauth_token(self):
vals = {'clover_oauth_access_token': new_token}
new_refresh = data.get('refresh_token', '')
if new_refresh:
vals['clover_oauth_refresh_token'] = new_refresh # CRITICAL
```
**Critical**: Clover sandbox uses **single-use rotating refresh tokens**.
Every refresh response includes a NEW refresh_token; the old one is
**immediately invalidated**. If you forget to store the new one, the
NEXT refresh attempt will fail with `401 Invalid refresh token` and
the merchant's connection is dead until they re-OAuth.
---
# Clover sandbox quirks (learned the hard way)
These tripped us up during the sandbox build-out. Documenting so
future deploys don't waste hours on them.
## OAuth + dev app
1. **OAuth v2 authorize lives on `sandbox.dev.clover.com`**, NOT
`apisandbox.dev.clover.com`. The latter is API-only with no login UI.
Wrong host → blank login page that rejects every password.
2. **Path is `/oauth/v2/authorize`** — Clover deprecated `/oauth/authorize`
in October 2023. Old path still resolves but generates v1-only codes
that fail at v2 token endpoint with `Failed to validate authentication
code` 401.
3. **Token endpoint is on `apisandbox.dev.clover.com`** (not the same
host as authorize) — Clover splits authorize (UI) from token (API).
4. **Refresh tokens are single-use** — see "OAuth token lifecycle" above.
5. **Sandbox access_token lifetime is ~30 minutes** — production is
typically longer. Auto-refresh is essential for any long-running
process.
6. **Test merchant accounts have NO password by default** — you have to
set one via Test Merchant Dashboard → Profile → Edit your profile,
OR reset via dev dashboard. Until you do, OAuth login fails even
with the dev account password.
## App configuration
7. **Alternate Launch Path is REQUIRED** for App Market Connect to work.
Without it, Clover's "Connect" button uses the legacy partial OAuth
flow which generates v1-style codes that fail at /v2/token. Set it
to `/clover/launch` (path only — Clover prepends the Site URL host).
8. **Pricing & Distribution must be configured before any test merchant
can install** — even draft apps. Symptom: hung page after merchant
selection, browser console shows 404 on `/v3/merchants/{mid}/apps/{appId}?expand=...availableSubscriptions,billing`.
Fix: set price to Free (or whatever) before testing.
9. **Modules Availability "Register Pre-Auth" + "Orders" require Register
service plan** — default test merchants don't have it, install fails
with "This app is not available with your service plan". Drop those
modules (we don't actually use them — terminal pre-auth uses REST
Pay Display, not Register).
10. **App Market Connect** uses the legacy "partial OAuth flow" if no
Alternate Launch Path is set — bypasses `/oauth/v2/authorize`,
generates v1 codes, fails. With Alternate Launch Path set, Clover
bounces through it → properly initiates v2 flow.
## Ecommerce API
11. **Tokenization endpoint header is `apikey`** (lowercase), NOT
`apiAccessKey`. The PAKMS endpoint *returns* a field called
`apiAccessKey` but the tokenize endpoint *requires* a header called
`apikey`. Wrong header → 401.
12. **PAKMS endpoint is on the Ecommerce host** (`scl-sandbox.dev.clover.com/pakms/apikey`),
NOT the Platform host (`apisandbox.dev.clover.com/pakms/apikey` returns 404).
13. **Tokenize requires real brand value** — `VISA`, `MC`, `AMEX`,
`DISCOVER`, `DINERS`, `JCB`. Don't pass `CARD` — Clover rejects.
See `_clover_detect_brand_from_pan()` in `payment_provider.py`.
14. **`/v1/refunds` accepts ONLY `{"charge": "..."}`** — adding `amount`
or `reason` triggers `Invalid JSON format` 400. It is **full-refund
only**. For partials use `/v1/payments/{paymentId}/refunds` with
`{"amount": cents}` or `{"fullRefund": true}`. See
`utils.build_payment_refund_payload()`.
15. **Past `exp_year` accepted in sandbox** (e.g. `1970`) — production
will reject. Don't rely on the tokenize endpoint to validate this.
16. **Per-card velocity limit in sandbox** — after ~6-10 charges on the
same test card, sandbox declines with `"Declined as sale count per
card is greater than configured amount"`. Production has higher
limits. Workaround: use a different test card or different test
merchant.
## Webhook configuration
17. **Webhook events: PAYMENTS + APP are sufficient** for our use case.
Skip Customers / Inventory / Merchants / Cash / Employees — Odoo is
source of truth for those.
18. **Webhook payload format is the merchant-firehose, NOT Hosted
Checkout style** — we get `{"appId": "...", "merchants": {"<mid>":
[{"objectId": "P:<mid>/<id>", "type": "CREATE", "ts": ...}]}}`,
NOT `{"type": "charge.succeeded", "data": {...}}`. Object IDs are
prefixed: `P:` payment, `O:` order, `C:` customer, etc. The handler
at `controllers/main.py::_dispatch_clover_webhook` accepts both shapes.
19. **Verification challenge is one-shot, no signature** — Clover POSTs
`{"verificationCode": "<uuid>"}` once. The Odoo handler
short-circuits HMAC verification for this specific body shape.
## REST Pay Display / Terminal
20. **`X-POS-Id` header MUST be the Remote App ID (RAID)** in production —
not a free-form string. RAID is generated when App Type is set to
`Web` → "Is this an integration of an existing POS = Yes".
21. **Cloud Pay Display app must be installed AND running on the
Clover device** before any `/connect/v1/payments` call works. The
merchant has to manually start it after install.
22. **REST Pay Display only works with OAuth tokens** — Ecommerce
private tokens 401. Documented as: *"Integrators must use OAuth
tokens to connect to the Clover device using this app."*
---
# Version history (2026-04-28 / 29 build session)
| Version | Critical change |
|---|---|
| `19.0.1.0.0` | Inherited buggy state — broken portal flow, no OAuth, missing icon, raw PAN to Clover |
| `19.0.1.1.0` | Initial review-pass: removed broken logo ref, rewrote settings view to MANDATORY layout, **integrated Clover.js iframe SDK** for portal tokenization (PCI-compliant), added HMAC webhook verification, added server-side tokenization for back-office wizard |
| `19.0.1.2.0` | Multi-tenant OAuth fields + dispatcher integration: `clover_remote_app_id`, `clover_oauth_access_token`, `clover_oauth_refresh_token`, `clover_oauth_token_expiry` + `action_clover_oauth_connect` button + auth-precedence helper |
| `19.0.1.3.0` | Webhook verification challenge handling + dual-format dispatch (legacy `{type, data}` AND new firehose `{merchants}` format) |
| `19.0.1.4.0` | Ecommerce API also uses OAuth token (was hardcoded to ecom private key) |
| `19.0.1.5.0` | **Fixed wrong OAuth URLs** — was using legacy `/oauth/authorize` on wrong host. Switched to v2 endpoints (`sandbox.dev.clover.com/oauth/v2/authorize`, `apisandbox.dev.clover.com/oauth/v2/token`) per Clover's Oct-2023 mandate |
| `19.0.1.6.0` | OAuth token-exchange v1 fallback for legacy partial-flow codes |
| `19.0.1.7.0` | Tokenization fixes: `apikey` header (was `apiAccessKey`) + brand auto-detection from PAN BIN |
| `19.0.1.8.0` | **Fixed `/v1/refunds` payload** — Clover rejects with 400 on any field other than `{charge}`. Added separate `build_payment_refund_payload` for partials via `/v1/payments/{id}/refunds` |
| `19.0.1.9.0` | OAuth auto-refresh: proactive (within 60s of expiry) + reactive (on 401, retry once with `_retry=False` to prevent infinite loops) on Platform/Ecom/Terminal request methods |
| **`19.0.1.10.0`** | `_clover_make_ecom_request` now properly routes through `_clover_get_platform_token()` so Ecommerce calls also benefit from auto-refresh |
---
# Battle test results (2026-04-29)
End-to-end validation against sandbox Test Merc 2 (`ASVPRFJ5D5GF1`):
## Payment lifecycle PROVEN
| Operation | Endpoint | Status |
|---|---|---|
| OAuth v2 handshake | `/oauth/v2/authorize` → `/oauth/v2/token` | ✅ |
| PAKMS fetch via OAuth | `/pakms/apikey` | ✅ |
| Server-side tokenization | `/v1/tokens` | ✅ |
| Charge creation | `POST /v1/charges` | ✅ Charge `BSEQZTNJKQGY6` $1.00 succeeded |
| Full refund | `POST /v1/refunds` | ✅ Refund `N97G2QE705Q4T` $2.00 succeeded |
| Platform API auth | `/v3/merchants/{mId}` | ✅ Returned "Test Merc 2" |
| OAuth refresh | `/oauth/v2/refresh` | ✅ New JWT issued and authenticated |
## Failure-mode handling PROVEN
| Test | Result |
|---|---|
| Visa decline `4264281511117771` | ✅ 402 with `DECLINED` raised cleanly |
| Mastercard decline `5424180273333333` | ✅ 402 raised |
| Bad-Luhn card `4242424242424241` | ✅ 400 at tokenize: "Please provide valid card number" |
| Invalid month=13 | ✅ 400 "Please provide valid expiry month" |
| 2-digit CVV | ✅ 400 "Please provide valid cvv value" |
| **Idempotency: same key 2x** | ✅ Same charge_id returned, NO double-charge |
| **Forged HMAC OAuth state** | ✅ 403 from dispatcher in 1.5ms |
| **Disallowed redirect host** | ✅ 403 from dispatcher allow-list |
## Hammer test results (Cloudflare Worker)
| Stress scenario | Result | Throughput / latency |
|---|---|---|
| 100 concurrent webhooks | ✅ | 347 req/s, 100/100 OK, 0 drops |
| 9.4MB JSON body (DoS attempt) | ✅ | HTTP 200 in 1.7s, no OOM |
| 50 forged HMACs in parallel | ✅ | 50/50 → 403 in 77ms (~650 verifications/sec) |
| 1001 merchantIds in single payload | ✅ | 47ms (O(n) routing scales fine) |
| 200 concurrent /healthz | ✅ | 500 req/s, 200/200 OK |
| 50 webhooks fan-out → Odoo | ✅ | All forwarded; **Odoo `/web/login` stayed at 116ms during flood** |
| Odoo HMAC verifier under flood | ✅ | All 50 unsigned webhooks rejected with 403 (correct security behaviour, fast rejection) |
| **Recursive 401 prevention** | ✅ | Failed cleanly in **0.77 seconds** when both tokens corrupt — no infinite loop |
The recursive-401 test is the most important — without `_retry=False`
on the inner call, a corrupt-credentials scenario would loop forever
hammering Clover's API and getting our IP rate-limited. 0.77s vs
hours-of-API-spam.
---
# Westin Healthcare deployment
## Hosts (don't confuse them)
| Host | What | Notes |
|---|---|---|
| `192.168.1.152` (alias `westin@`) | WordPress site `westinhealthcare.ca` | NOT the ERP. Don't deploy here. |
| `192.168.1.40` (alias `odoo-westin`, vmid 101) | **PRODUCTION Odoo 19 ERP** for Westin | DB `westin-v19`, custom addons at `/opt/odoo/custom-addons/`, container `odoo-dev-app` (misleadingly named) |
**The `odoo-dev-app` container ON `odoo-westin` IS PRODUCTION.** Per
`../.cursor/rules/environment-safety.mdc`:
> ssh alias `odoo-westin` (192.168.1.40, erp.westinhealthcare.ca) is
> PRODUCTION. `docker exec odoo-dev-app ...` via this ssh alias touches
> PRODUCTION despite the "-dev" in the container name.
## Backup pattern (used between every deploy)
Backups live OUTSIDE `/opt/odoo/custom-addons/` because Odoo refuses
module folder names with dots — putting them inside causes
`FileNotFoundError: Invalid module name: fusion_clover.bak.YYYYMMDD-HHMMSS`
on next startup.
```bash
ssh odoo-westin '
cd /opt/odoo/custom-addons
cp -a fusion_clover /opt/odoo/backups/addons-bak/fusion_clover.bak.$(date +%Y%m%d-%H%M%S)
rm -rf fusion_clover
# ...rsync new code...
'
```
## Production deploy (after sandbox sign-off — same pattern as fusion_poynt)
```bash
# 1. Tar + scp (rsync not available on Windows powershell)
cd k:/Github/Odoo-Modules
tar --exclude='__pycache__' --exclude='graphify-out' --exclude='CLAUDE.md' \
--exclude='.git' --exclude='agent-tools' \
-czf /tmp/fusion_clover.tgz fusion_clover
scp /tmp/fusion_clover.tgz odoo-westin:/tmp/fc.tgz
# 2. Backup, replace, upgrade
ssh odoo-westin '
set -e
cd /opt/odoo/custom-addons
cp -a fusion_clover /opt/odoo/backups/addons-bak/fusion_clover.bak.$(date +%Y%m%d-%H%M%S)
rm -rf fusion_clover
mkdir -p /tmp/fc_extract && tar -xzf /tmp/fc.tgz -C /tmp/fc_extract
mv /tmp/fc_extract/fusion_clover /opt/odoo/custom-addons/fusion_clover
rm -rf /tmp/fc_extract
cd /opt/odoo
docker compose stop odoo
docker compose run --rm --no-deps odoo \
-c /etc/odoo/odoo.conf -d westin-v19 -u fusion_clover \
--stop-after-init --no-http
docker compose up -d odoo
'
# 3. Smoke test
ssh odoo-westin 'curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8069/web/login'
```
## Production go-live checklist (when ready to flip from sandbox to prod)
1. **In Clover dev dashboard**: Pricing & Distribution → Submit for
Production → wait for Clover approval (1-3 business days first
time). Get production App ID, App Secret, Remote App ID. **Note:
production has SEPARATE App ID/Secret/RAID values from sandbox.**
2. **In Cloudflare Worker `wrangler.jsonc`**: update `CLOVER_APP_ID`
to the production App ID. The `MERCHANT_ROUTING_JSON` and
`MERCHANT_ODOO_BASE_JSON` should map Westin's REAL merchant ID
(`E2DYXYRBT52K1`) to `https://erp.westinhealthcare.ca/payment/clover/webhook`.
3. **In Cloudflare Worker secret**: rotate `DISPATCHER_SECRET` for
production launch (`npx wrangler secret put DISPATCHER_SECRET`).
Also update Westin's `ir.config_parameter` `fusion_clover.dispatcher_secret`
to match.
4. **In Westin Odoo** (`payment_provider` row 36):
- `clover_app_id` → production App ID
- `clover_app_secret` → production App Secret
- `clover_remote_app_id` → production RAID
- `clover_merchant_id` → `E2DYXYRBT52K1` (Westin's real merchant)
- `state` → `enabled` (NOT `test`)
- Clear all `clover_oauth_*` fields (force re-OAuth)
5. **On Westin's Clover account**: install the Nexa app from the
Clover App Market (production now-public version). Authorize.
6. **In Westin Odoo**: click **Connect to Clover** — runs production
OAuth, stores production access_token + refresh_token.
7. **Click Test Connection**: should return "Test Connection
successful. Merchant: WESTIN HEALTHCARE".
8. **Click Sync Terminals**: pulls Westin's real Clover terminals.
9. **Set Default Terminal** under Terminal Settings.
10. **Configure surcharge rates** in Settings → Fusion Clover (match
fusion_poynt rates so UX is consistent regardless of which
processor a clerk picks).
11. **Set webhook URL on Clover dashboard**: same dispatcher URL
`https://oauth.nexasystems.ca/clover/webhook`. Run verification
again (codes appear in `wrangler tail` and Odoo docker logs).
12. **Smoke test**: create a small test invoice, click Collect Clover
Payment, complete with a real card under $5, refund it. Verify
receipt email + PDF.
13. **Watch logs for the first few real transactions**:
`wrangler tail nexa-clover-oauth-dispatcher` and
`docker logs -f odoo-dev-app | grep -i clover`.
## Coexistence with fusion_poynt
Both modules can be installed at the same time. They use disjoint
prefixes (`clover_*` vs `poynt_*`) and disjoint API URLs. The
`account.move` Collect Payment buttons sit side by side. The two
surcharge configs are independent — set them to the same rates so end
users see consistent fees regardless of which processor the back-office
picks for a given invoice.
---
# Key Odoo 19 conventions enforced here
- `type="jsonrpc"` (not deprecated `type="json"`) — see all routes in `controllers/main.py`
- `Interaction` patch via `patch(PaymentForm.prototype, ...)` instead of IIFE / DOMContentLoaded
- `models.Constraint('UNIQUE(serial_number, provider_id)', ...)` on `clover.terminal` instead of legacy `_sql_constraints`
- `res.groups` use `privilege_id` (no `category_id`, no `users` field)
- Settings view uses MANDATORY `<block>` / `<setting id=…>` / `col-lg-5 o_light_label` layout
- Currency: `Monetary` fields with `currency_field=` not bare floats
---
# Outstanding TODOs
1. **Fix `datetime.utcfromtimestamp` deprecation warning** (Python 3.12
removes it). Use `datetime.fromtimestamp(ts, tz=datetime.UTC)` in
`_clover_exchange_oauth_code` and `_clover_refresh_oauth_token`.
2. **Wire partial refund support** through the wizard UI — backend
helper `build_payment_refund_payload` exists but isn't called from
the wizard yet. The wizard currently always does full refunds.
3. **Add automated tests** — `tests/` folder is empty. Battle test
script lives at `C:\Users\gur_p\AppData\Local\Temp\battle_test.py`
and `hammer_odoo.py` — should be cleaned up and committed as
actual `tests/test_*.py` files.
4. **Add OAuth disconnect button** to provider form so admins can
force-clear tokens without going to the DB. Useful when a customer
wants to revoke and re-authorize.
5. **Static module icon** at `static/description/icon.png` (currently
shows blank in module browser).
6. **Webhook event handlers for firehose format** — `_dispatch_clover_webhook`
currently logs `P:`-prefixed payment events but doesn't fetch the
payment object from Platform API to update the matching
`payment.transaction`. Wire this when actual reconciliation gaps
surface in production.
---
# Workflow / commands
```bash
# Local dev (CONFIRM THE LOCAL VM HOST FIRST — odoo-dev-app on
# odoo-westin is PRODUCTION)
docker exec <local-orbstack-container> odoo -d fusion-dev \
-u fusion_clover --stop-after-init
# Watch live OAuth + webhook flow on production
cd K:/Github/nexa-oauth-dispatcher
$env:CLOUDFLARE_API_KEY = "<key from fusionapps.ai_memory>"
$env:CLOUDFLARE_EMAIL = "gsingh@westinhealthcare.com"
npx wrangler tail nexa-clover-oauth-dispatcher --format pretty
# Generate fresh OAuth URL (1-hour TTL) for a manual login test
ssh odoo-westin "echo \"
provider = env['payment.provider'].sudo().search([('code', '=', 'clover')], limit=1)
print(provider.action_clover_oauth_connect()['url'])
\" | docker exec -i odoo-dev-app odoo shell -c /etc/odoo/odoo.conf -d westin-v19 --no-http 2>&1 | grep https"
# Force-refresh a stale OAuth token via shell
ssh odoo-westin "echo \"
provider = env['payment.provider'].sudo().search([('code', '=', 'clover')], limit=1)
ok = provider._clover_refresh_oauth_token()
print('refreshed:', ok, 'new expiry:', provider.clover_oauth_token_expiry)
env.cr.commit()
\" | docker exec -i odoo-dev-app odoo shell -c /etc/odoo/odoo.conf -d westin-v19 --no-http"
# Production deploy — see "Westin Healthcare deployment > Production deploy"
```
---
# References
- Workspace conventions: `../CLAUDE.md`
- Environment safety rule: `../.cursor/rules/environment-safety.mdc`
- Sister implementation: `../fusion_poynt/`
- Worker source: `K:/Github/nexa-oauth-dispatcher/`
- Production deploy log (fusion_poynt): Supabase
`fusionapps.work_sessions` "Deployed fusion_poynt to production
odoo-westin" (2026-02-24)
- Clover Authenticate v2 OAuth: https://docs.clover.com/docs/use-oauth
- Clover High-trust app auth flow: https://docs.clover.com/dev/docs/high-trust-app-auth-flow
- Clover Ecommerce API: https://docs.clover.com/reference/ecommerce-api
- Clover Hosted iframe (Web SDK): https://docs.clover.com/docs/using-the-clover-hosted-iframe
- Clover REST Pay Display Cloud: https://docs.clover.com/docs/rest-pay-overview
- Clover Test card numbers: https://docs.clover.com/dev/docs/test-card-numbers
- Clover Refund payments: https://docs.clover.com/dev/docs/ecommerce-refunding-payments

View File

@@ -2,11 +2,23 @@
{
'name': 'Payment Provider: Clover',
'version': '19.0.1.0.0',
'version': '19.0.1.12.0',
'category': 'Accounting/Payment Providers',
'sequence': 365,
'summary': "Clover payment processing for ecommerce, terminal, and manual card payments.",
'description': " ",
'description': """
Clover payment provider for Odoo 19.
Supports:
- Online card payments via Clover.js iframe (PCI-compliant, customer
card never touches Odoo)
- In-person terminal payments via Cloud REST Pay Display
- Back-office manual card collection (server-side tokenization)
- Referenced + non-referenced refunds, voids, and credits
- Multi-card surcharge (Visa/Mastercard/Amex/Debit/Other)
- Webhook signature verification (HMAC-SHA256)
- Payment receipt PDF generation and email delivery
""",
'depends': ['payment', 'account_payment', 'sale'],
'data': [
'security/security.xml',

View File

@@ -17,11 +17,18 @@ TOKEN_BASE_URL_TEST = 'https://token-sandbox.dev.clover.com'
CONNECT_BASE_URL = 'https://api.clover.com/connect/v1'
CONNECT_BASE_URL_TEST = 'https://apisandbox.dev.clover.com/connect/v1'
# OAuth URLs
OAUTH_AUTHORIZE_URL_TEST = 'https://apisandbox.dev.clover.com/oauth/authorize'
OAUTH_AUTHORIZE_URL = 'https://api.clover.com/oauth/authorize'
OAUTH_TOKEN_URL_TEST = 'https://apisandbox.dev.clover.com/oauth/token'
OAUTH_TOKEN_URL = 'https://api.clover.com/oauth/token'
# OAuth v2 URLs (Clover mandated v2 from October 2023; legacy /oauth/authorize
# is deprecated). Note that the AUTHORIZE endpoint is on the merchant/dev
# portal host (sandbox.dev.clover.com / www.clover.com) — NOT on the API
# host — because it has to render a merchant-facing login + consent UI.
# The TOKEN/REFRESH endpoints are on the API host.
# https://docs.clover.com/docs/use-oauth#sandbox-and-production-environment-urls
OAUTH_AUTHORIZE_URL_TEST = 'https://sandbox.dev.clover.com/oauth/v2/authorize'
OAUTH_AUTHORIZE_URL = 'https://www.clover.com/oauth/v2/authorize'
OAUTH_TOKEN_URL_TEST = 'https://apisandbox.dev.clover.com/oauth/v2/token'
OAUTH_TOKEN_URL = 'https://api.clover.com/oauth/v2/token'
OAUTH_REFRESH_URL_TEST = 'https://apisandbox.dev.clover.com/oauth/v2/refresh'
OAUTH_REFRESH_URL = 'https://api.clover.com/oauth/v2/refresh'
DEFAULT_PAYMENT_METHOD_CODES = {
'card',

View File

@@ -1,5 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import hashlib
import hmac
import json
import logging
import pprint
@@ -73,24 +75,95 @@ class CloverController(http.Controller):
@http.route(_webhook_url, type='http', methods=['POST'], auth='public', csrf=False)
def clover_webhook(self):
"""Process webhook notifications from Clover."""
"""Process webhook notifications from Clover.
Verifies the HMAC signature (when an app secret is configured)
before processing. Clover signs webhooks with HMAC-SHA256 over
the raw request body using the app secret as the key, and sends
the signature in the ``X-Clover-Auth-Code`` header (hex-encoded).
Webhooks for the ecom-style providers may also use the legacy
``X-Clover-Signature`` header — both are checked.
Also handles Clover's one-time URL verification challenge:
when a developer clicks "Send Verification Code" in the Clover
dashboard, Clover POSTs ``{"verificationCode": "<uuid>"}`` to
the URL. We log it loudly so the developer can grab it from the
Odoo log instead of fishing through Cloudflare logs (the same
code is also logged by the Nexa dispatcher Worker if used).
"""
raw_body = request.httprequest.data
try:
raw_body = request.httprequest.data.decode('utf-8')
event = json.loads(raw_body)
event = json.loads(raw_body.decode('utf-8'))
except (ValueError, UnicodeDecodeError):
_logger.warning("Received invalid JSON from Clover webhook")
return request.make_json_response({'status': 'error'}, status=400)
# --- Verification challenge ---------------------------------------
# This special POST is sent ONCE when the dev dashboard's "Send
# Verification Code" button is clicked. It is NOT signed (Clover
# has no signature to add yet — the webhook isn't activated),
# so we accept it without HMAC verification.
verification_code = event.get('verificationCode') if isinstance(event, dict) else None
if verification_code:
_logger.warning(
"================================================================\n"
"CLOVER WEBHOOK VERIFICATION CODE (paste into Clover dashboard):\n"
" %s\n"
"================================================================",
verification_code,
)
return request.make_json_response({
'status': 'ok',
'verification': verification_code,
})
if not self._verify_webhook_signature(raw_body):
_logger.warning(
"Clover webhook signature verification FAILED — "
"rejecting payload."
)
return request.make_json_response(
{'status': 'forbidden'}, status=403,
)
_logger.info(
"Clover webhook notification received:\n%s",
pprint.pformat(event),
)
try:
event_type = event.get('type', '')
data = event.get('data', {})
self._dispatch_clover_webhook(event)
except ValidationError:
_logger.exception("Unable to process Clover webhook; acknowledging to avoid retries")
if event_type in ('charge.succeeded', 'charge.captured'):
return request.make_json_response({'status': 'ok'})
def _dispatch_clover_webhook(self, event):
"""Route a Clover webhook payload to the appropriate handler.
Clover uses two payload shapes depending on the integration:
1. **Hosted Checkout / Ecommerce style** —
``{"type": "charge.succeeded", "data": {...}}``
2. **Merchant App firehose style** —
``{"appId": "...", "merchants": {"<mId>": [{"objectId": "P:xxx",
"type": "CREATE", "ts": 1234567890}, ...]}}``
The two shapes carry different information density. We handle
format 1 directly and decode format 2 into best-effort polls of
the underlying object (e.g. fetch the payment from Platform API
and synthesise a charge.succeeded equivalent).
"""
if not isinstance(event, dict):
return
# --- Format 1: domain events ------------------------------------
if 'type' in event and 'data' in event:
event_type = event.get('type', '')
data = event.get('data', {}) or {}
if event_type in ('charge.succeeded', 'charge.captured', 'payment.created'):
self._handle_charge_webhook(data, 'succeeded')
elif event_type == 'charge.failed':
self._handle_charge_webhook(data, 'failed')
@@ -100,13 +173,83 @@ class CloverController(http.Controller):
self._handle_refund_webhook(data)
elif event_type == 'refund.failed':
_logger.warning("Clover refund failed webhook: %s", data.get('id', ''))
elif event_type == 'payment.created':
self._handle_charge_webhook(data, 'succeeded')
else:
_logger.info("Unhandled Clover webhook event type: %s", event_type)
return
except ValidationError:
_logger.exception("Unable to process Clover webhook; acknowledging to avoid retries")
# --- Format 2: merchant-app firehose ----------------------------
merchants_block = event.get('merchants') or {}
if not isinstance(merchants_block, dict):
return
for merchant_id, events in merchants_block.items():
if not isinstance(events, list):
continue
for ev in events:
if not isinstance(ev, dict):
continue
object_ref = ev.get('objectId') or ''
# objectId looks like "P:E2DYXYRBT52K1/abcd1234" (Payment),
# "C:<mid>/<id>" (Customer), "O:<mid>/<id>" (Order), etc.
kind = object_ref[:2] if len(object_ref) >= 2 else ''
action = ev.get('type', '')
_logger.info(
"Clover firehose event: merchant=%s objectId=%s action=%s",
merchant_id, object_ref, action,
)
if kind == 'P:' and action in ('CREATE', 'UPDATE'):
# Don't synchronously fetch from the Platform API
# here — we'd block Clover's webhook ack timeout.
# If recovery from missed sync responses becomes a
# real need, schedule a queue_job and return 200 fast.
_logger.debug(
"Payment object change for merchant %s: %s (%s) — "
"no synchronous handler, see _dispatch_clover_webhook",
merchant_id, object_ref, action,
)
return request.make_json_response({'status': 'ok'})
def _verify_webhook_signature(self, raw_body):
"""Validate the HMAC signature on a Clover webhook payload.
:param bytes raw_body: The raw request body, exactly as received.
:return: True if the signature is valid OR if no provider has a
secret configured (development/sandbox mode). False if a
secret is configured but the signature does not match.
:rtype: bool
"""
# Find any Clover provider with an app secret configured. If none,
# we silently allow the webhook (sandbox/dev). If at least one has
# a secret, signatures become mandatory.
providers = request.env['payment.provider'].sudo().search([
('code', '=', 'clover'),
('clover_app_secret', '!=', False),
])
if not providers:
return True
sig_header = (
request.httprequest.headers.get('X-Clover-Auth-Code')
or request.httprequest.headers.get('X-Clover-Signature')
or ''
)
if not sig_header:
return False
sig_header_clean = sig_header.lower().strip()
if sig_header_clean.startswith('sha256='):
sig_header_clean = sig_header_clean[len('sha256='):]
for provider in providers:
secret = provider.clover_app_secret
if not secret:
continue
expected = hmac.new(
secret.encode('utf-8'),
raw_body,
hashlib.sha256,
).hexdigest().lower()
if hmac.compare_digest(expected, sig_header_clean):
return True
return False
def _handle_charge_webhook(self, data, status):
"""Process a charge-related webhook event."""
@@ -200,62 +343,85 @@ class CloverController(http.Controller):
def clover_oauth_callback(self, **data):
"""Handle the OAuth2 authorization callback from Clover.
After a merchant authorizes the app, Clover redirects here with
an authorization code. We exchange it for an access token and
store the merchant_id.
After the merchant authorises the Nexa app, Clover redirects to
the Nexa OAuth dispatcher (https://oauth.nexasystems.ca/clover/
callback), which verifies the signed state and 302-redirects
here with the original code/merchant_id/state query params.
Note: the dispatcher already verified the HMAC signature on
``state`` before forwarding. We re-verify here as defence in
depth — an attacker who tricks the user into hitting this
callback directly (skipping the dispatcher) must still know the
dispatcher secret to forge a valid state.
"""
code = data.get('code', '')
merchant_id = data.get('merchant_id', '')
client_id = data.get('client_id', '')
state = data.get('state', '')
if not code:
_logger.warning("Clover OAuth callback missing authorization code")
return request.redirect('/odoo/settings')
if state:
try:
provider_id = int(state)
provider = request.env['payment.provider'].browse(provider_id)
if provider.exists() and provider.code == 'clover':
# Exchange code for access token
import requests as req
is_test = provider.state == 'test'
token_url = (
f"{data.get('_token_url', '')}"
or (
'https://apisandbox.dev.clover.com/oauth/token' if is_test
else 'https://api.clover.com/oauth/token'
)
)
token_resp = req.get(token_url, params={
'client_id': provider.clover_app_id,
'client_secret': provider.sudo().clover_app_secret,
'code': code,
}, timeout=30)
# Locate the Clover provider record. There's normally one per
# company; we pick the most recently configured.
provider = request.env['payment.provider'].sudo().search([
('code', '=', 'clover'),
], order='id desc', limit=1)
if not provider:
_logger.warning("Clover OAuth callback but no Clover provider exists.")
return request.redirect('/odoo/settings')
if token_resp.status_code == 200:
token_data = token_resp.json()
access_token = token_data.get('access_token', '')
if access_token:
vals = {'clover_api_key': access_token}
if merchant_id:
vals['clover_merchant_id'] = merchant_id
provider.sudo().write(vals)
_logger.info(
"Clover OAuth: linked merchant %s to provider %s",
merchant_id, provider_id,
)
else:
_logger.error(
"Clover OAuth token exchange failed: %s",
token_resp.text[:500],
)
except (ValueError, TypeError):
_logger.warning("Invalid provider state in Clover OAuth callback: %s", state)
# Defence-in-depth: verify the state HMAC ourselves.
if state and not self._verify_dispatcher_state(state, provider):
_logger.error("Clover OAuth callback: invalid HMAC on state, refusing to exchange code.")
return request.redirect('/odoo/settings')
try:
provider._clover_exchange_oauth_code(code)
except (ValidationError, UserError) as e:
_logger.exception("Clover OAuth code exchange failed: %s", e)
return request.redirect('/odoo/settings')
if merchant_id and not provider.clover_merchant_id:
provider.sudo().write({'clover_merchant_id': merchant_id})
_logger.info(
"Clover OAuth: linked merchant %s to provider id=%s",
merchant_id or '(unknown)', provider.id,
)
return request.redirect('/odoo/settings')
def _verify_dispatcher_state(self, state, provider):
"""Recompute the HMAC on the dispatcher state and constant-time
compare. Skips the iat freshness check (the dispatcher already
did that) but enforces the signature."""
secret = provider._clover_dispatcher_secret()
if not secret:
# No secret configured -> dev/local mode where state is just
# decorative. Accept whatever the dispatcher already vetted.
return True
try:
payload_b64u, sig_b64u = state.rsplit('.', 1)
except ValueError:
return False
expected = hmac.new(
secret.encode('utf-8'),
payload_b64u.encode('ascii'),
hashlib.sha256,
).digest()
try:
received = self._b64u_decode(sig_b64u)
except Exception:
return False
return hmac.compare_digest(expected, received)
@staticmethod
def _b64u_decode(s):
"""Decode a base64url string with optional padding."""
import base64
padded = s + '=' * (-len(s) % 4)
return base64.urlsafe_b64decode(padded.encode('ascii'))
# === SURCHARGE HELPER === #
def _apply_portal_surcharge(self, tx_sudo, card_type):
@@ -433,12 +599,19 @@ class CloverController(http.Controller):
@http.route('/payment/clover/process_card', type='jsonrpc', auth='public')
def clover_process_card(self, reference=None, card_token=None,
card_type=None, **kwargs):
"""Process a card payment through Clover Ecommerce API.
card_type=None, save_token=False, **kwargs):
"""Process a card payment through the Clover Ecommerce API.
The frontend tokenizes the card via Clover's iframe/API and sends
the token here. Card data is NOT stored in Odoo.
The frontend MUST tokenize the card client-side using Clover.js
(https://docs.clover.com/docs/web-sdk) and send only the
resulting ``clv_xxx`` token here. Raw PAN must never reach Odoo
(PCI scope).
:param str reference: The Odoo payment.transaction reference.
:param str card_token: The Clover.js source token (``clv_xxx``).
:param str card_type: Optional detected card brand for surcharge.
:param bool save_token: Whether to persist the token for future
charges (card-on-file).
:return: Dict with success status or error message.
:rtype: dict
"""
@@ -453,12 +626,17 @@ class CloverController(http.Controller):
if not tx_sudo:
return {'error': 'Transaction not found.'}
if not card_token:
return {'error': 'Missing card token. Please try again.'}
if not card_token or not isinstance(card_token, str) \
or not card_token.startswith('clv_'):
return {
'error': 'Missing or invalid Clover token. '
'The card must be tokenized via Clover.js before '
'submission.',
}
try:
if card_type:
surcharge_fee = self._apply_portal_surcharge(tx_sudo, card_type)
self._apply_portal_surcharge(tx_sudo, card_type)
provider = tx_sudo.provider_id.sudo()
capture = not provider.capture_manually
@@ -470,6 +648,7 @@ class CloverController(http.Controller):
capture=capture,
description=reference,
ecomind='ecom',
receipt_email=tx_sudo.partner_id.email or '',
metadata={'odoo_reference': reference},
)
@@ -487,11 +666,15 @@ class CloverController(http.Controller):
'clover_status': status,
'source': result.get('source', {}),
}
if save_token:
# Mark the transaction so _extract_token_values fires on
# _apply_updates.
tx_sudo.tokenize = True
tx_sudo._process('clover', payment_data)
return {'success': True, 'status': status}
except ValidationError as e:
return {'error': str(e)}
except Exception as e:
_logger.error("Card payment processing failed: %s", e)
_logger.exception("Card payment processing failed: %s", e)
return {'error': 'Payment processing failed. Please try again.'}

View File

@@ -7,7 +7,8 @@
<field name="inline_form_view_id" ref="inline_form"/>
<field name="allow_tokenization">True</field>
<field name="state">disabled</field>
<field name="image_128" type="base64" file="fusion_clover/static/src/img/clover_logo.png"/>
<!-- Logo is uploaded by the merchant via the UI after install
(Settings > Payment Providers > Clover > upload image_128). -->
</record>
</odoo>

View File

@@ -56,12 +56,43 @@ class PaymentProvider(models.Model):
copy=False,
groups='base.group_system',
)
clover_remote_app_id = fields.Char(
string="Remote App ID (RAID)",
help="The Remote App ID generated when the Clover dev app's "
"App Type is set to Web > 'Is this an integration of an "
"existing point of sale?' = Yes. Sent in the X-POS-Id "
"header on every REST Pay Display call. Required for "
"production terminal payments.",
copy=False,
)
clover_public_key = fields.Char(
string="Public API Key (PAKMS)",
help="The public token from Clover's Ecommerce API Tokens page. "
"Used for client-side tokenization. Safe to expose in the browser.",
copy=False,
)
clover_oauth_access_token = fields.Char(
string="OAuth Access Token",
help="Long-lived merchant OAuth access_token, obtained via the "
"Connect to Clover button. Used as the Bearer token for "
"all Platform API and REST Pay Display calls. Different "
"from the Ecommerce private token (clover_api_key).",
copy=False,
groups='base.group_system',
)
clover_oauth_refresh_token = fields.Char(
string="OAuth Refresh Token",
help="Refresh token used to renew the access token before it "
"expires.",
copy=False,
groups='base.group_system',
)
clover_oauth_token_expiry = fields.Datetime(
string="OAuth Token Expiry",
help="When the current access_token expires. The refresh flow "
"should run automatically before this time.",
copy=False,
)
clover_default_terminal_id = fields.Many2one(
'clover.terminal',
string="Default Terminal",
@@ -90,9 +121,292 @@ class PaymentProvider(models.Model):
return super()._get_default_payment_method_codes()
return const.DEFAULT_PAYMENT_METHOD_CODES
# === BUSINESS METHODS - OAUTH HELPERS === #
def _clover_get_platform_token(self):
"""Pick the best Bearer token for Platform / REST Pay Display calls.
Priority order:
1. Live OAuth access_token (preferred — refreshable, app-scoped,
grants both Platform API and REST Pay Display).
2. Manual REST API token from Clover Dashboard > Setup > API Tokens
(legacy / single-merchant fallback).
3. Ecommerce private token (last-resort — works only for the
Platform GET /v3/merchants/{mId} test-connection call;
will 401 on REST Pay Display).
Will proactively refresh the OAuth access_token if it's expired
(or within a 60-second grace window of expiry).
"""
self.ensure_one()
if self.clover_oauth_access_token:
self._clover_refresh_oauth_if_needed()
return self.clover_oauth_access_token
if self.clover_rest_api_token:
return self.clover_rest_api_token
return self.clover_api_key or ''
def _clover_refresh_oauth_if_needed(self, grace_seconds=60):
"""Refresh the OAuth access_token if it's within ``grace_seconds``
of expiry. Silent no-op if no expiry / no refresh_token / refresh
endpoint not available. Logs at WARNING on refresh failure but
does not raise (the next API call will get 401 and may trigger
retry-with-refresh from the request method)."""
from datetime import datetime, timedelta
self.ensure_one()
if not self.clover_oauth_refresh_token:
return False
if self.clover_oauth_token_expiry:
time_left = self.clover_oauth_token_expiry - datetime.utcnow()
if time_left > timedelta(seconds=grace_seconds):
return False # plenty of time
return self._clover_refresh_oauth_token()
def _clover_refresh_oauth_token(self):
"""Use the stored refresh_token to mint a new access_token via
Clover's /oauth/v2/refresh. Stores the new token + new expiry on
the provider record. Returns True on success, False on failure
(caller decides whether to fall back or raise)."""
from datetime import datetime, timedelta
self.ensure_one()
if not self.clover_oauth_refresh_token or not self.clover_app_id:
return False
is_test = self.state == 'test'
url = const.OAUTH_REFRESH_URL_TEST if is_test else const.OAUTH_REFRESH_URL
try:
r = requests.post(
url,
json={
'client_id': self.clover_app_id,
'refresh_token': self.clover_oauth_refresh_token,
},
timeout=20,
)
except requests.exceptions.RequestException as e:
_logger.warning("Clover OAuth refresh network error: %s", e)
return False
if r.status_code != 200:
_logger.warning(
"Clover OAuth refresh failed (%s): %s",
r.status_code, r.text[:300],
)
return False
try:
data = r.json()
except ValueError:
_logger.warning("Clover OAuth refresh response is not JSON")
return False
new_token = data.get('access_token', '')
if not new_token:
return False
vals = {'clover_oauth_access_token': new_token}
new_refresh = data.get('refresh_token', '')
if new_refresh:
vals['clover_oauth_refresh_token'] = new_refresh
expires = int(data.get('access_token_expiration', 0) or 0)
if expires:
if expires > 10 * 365 * 24 * 3600: # absolute timestamp
vals['clover_oauth_token_expiry'] = datetime.utcfromtimestamp(expires)
else: # duration in seconds
vals['clover_oauth_token_expiry'] = (
datetime.utcnow() + timedelta(seconds=expires)
)
self.sudo().write(vals)
_logger.info("Clover OAuth access_token refreshed for provider %s", self.id)
return True
def _clover_dispatcher_url(self):
"""Return the Nexa OAuth dispatcher URL (where Clover redirects
after the merchant authorises). Configurable per-deployment via
ir.config_parameter ``fusion_clover.dispatcher_url``."""
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_clover.dispatcher_url',
'https://oauth.nexasystems.ca/clover/callback',
)
def _clover_dispatcher_secret(self):
"""Return the HMAC secret shared with the Nexa OAuth dispatcher.
Stored in ``ir.config_parameter`` ``fusion_clover.dispatcher_secret``
so it can be rotated without a code deploy. Returns '' if not
configured (in which case the OAuth flow falls back to a direct
callback to this Odoo instance — useful for local dev)."""
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_clover.dispatcher_secret', '',
)
def _clover_build_signed_state(self, customer_slug=''):
"""Build the HMAC-SHA256 signed state token sent to Clover's
OAuth authorize endpoint. The Nexa dispatcher Worker verifies
this signature and uses the embedded ``redirect_to`` to fan the
OAuth callback back to THIS Odoo instance.
Format: ``<base64url(payload_json)>.<base64url(hmac_sha256(secret, payload))>``
"""
import base64
import hashlib
import hmac as _hmac
import json as _json
import secrets
import time
self.ensure_one()
secret = self._clover_dispatcher_secret()
if not secret:
raise UserError(_(
"The dispatcher HMAC secret is not configured. Set "
"the system parameter "
"'fusion_clover.dispatcher_secret' to the value "
"stored in the Cloudflare Worker before initiating "
"the Connect to Clover flow."
))
base_url = self.get_base_url()
payload = {
'redirect_to': f"{base_url}/payment/clover/oauth/callback",
'nonce': secrets.token_hex(16),
'iat': int(time.time()),
'customer': customer_slug or self.company_id.name or '',
}
payload_json = _json.dumps(payload, separators=(',', ':')).encode('utf-8')
payload_b64u = base64.urlsafe_b64encode(payload_json).rstrip(b'=').decode('ascii')
sig = _hmac.new(
secret.encode('utf-8'),
payload_b64u.encode('ascii'),
hashlib.sha256,
).digest()
sig_b64u = base64.urlsafe_b64encode(sig).rstrip(b'=').decode('ascii')
return f"{payload_b64u}.{sig_b64u}"
def action_clover_oauth_connect(self):
"""Kick off the OAuth flow: redirect the staff user to Clover's
authorize endpoint, with our dispatcher URL as redirect_uri and
a signed state encoding this Odoo's callback URL."""
from werkzeug.urls import url_encode
self.ensure_one()
if self.code != 'clover':
raise UserError(_("This action is only available for Clover providers."))
if not self.clover_app_id:
raise UserError(_(
"App ID is required. Add it on the payment provider record "
"under 'OAuth (Optional)' first."
))
is_test = self.state == 'test'
authorize_url = (
const.OAUTH_AUTHORIZE_URL_TEST if is_test else const.OAUTH_AUTHORIZE_URL
)
state = self._clover_build_signed_state()
# Per https://docs.clover.com/dev/docs/high-trust-app-auth-flow the
# v2 authorize endpoint only takes client_id + redirect_uri.
# `state` is documented elsewhere as supported for CSRF; we include
# it. Notably ABSENT: `response_type` (legacy v1 only) and `scope`
# (handled by app's Requested Permissions in the dev dashboard).
params = {
'client_id': self.clover_app_id,
'redirect_uri': self._clover_dispatcher_url(),
'state': state,
}
return {
'type': 'ir.actions.act_url',
'url': f"{authorize_url}?{url_encode(params)}",
'target': 'self',
}
def _clover_exchange_oauth_code(self, code):
"""Exchange a one-time OAuth ``code`` for a long-lived
access_token + refresh_token by POSTing to Clover's token
endpoint with our App Secret. Stores the tokens on this
provider record.
Tries the v2 token endpoint first; if Clover responds with 401
"Failed to validate authentication code", it means the code was
generated by the legacy partial OAuth flow (App Market Connect
without an Alternate Launch Path) — in that case we fall back to
the v1 token endpoint which is what generated the code.
"""
from datetime import datetime, timedelta
self.ensure_one()
if not self.clover_app_id or not self.clover_app_secret:
raise ValidationError(_(
"Cannot exchange OAuth code: App ID or App Secret is missing."
))
is_test = self.state == 'test'
v2_url = const.OAUTH_TOKEN_URL_TEST if is_test else const.OAUTH_TOKEN_URL
# Legacy v1 token URL — same host, no /v2/.
v1_url = v2_url.replace('/oauth/v2/token', '/oauth/token')
payload = {
'client_id': self.clover_app_id,
'client_secret': self.clover_app_secret,
'code': code,
}
data = None
last_error = ''
for token_url in (v2_url, v1_url):
try:
# v1 endpoint historically used GET with query params;
# v2 uses POST with JSON body. We send POST+JSON to v1
# too (Clover accepts both there in our experience).
response = requests.post(token_url, json=payload, timeout=30)
except requests.exceptions.RequestException as e:
raise ValidationError(_("Could not reach Clover OAuth token endpoint: %s", e))
if response.status_code < 400:
try:
data = response.json()
except ValueError:
raise ValidationError(_("Clover OAuth response was not valid JSON."))
_logger.info("Clover OAuth code exchanged successfully via %s", token_url)
break
last_error = response.text[:500]
_logger.warning(
"Clover OAuth exchange via %s returned %s: %s — trying next endpoint",
token_url, response.status_code, last_error,
)
# Only try v1 fallback if v2 specifically said "Failed to
# validate authentication code" — other 4xx errors won't
# benefit from the fallback.
if 'validate authentication code' not in last_error.lower():
break
if data is None:
raise ValidationError(_(
"Clover rejected the OAuth code at both v2 and v1 token endpoints. "
"Last response: %s", last_error,
))
access_token = data.get('access_token', '')
refresh_token = data.get('refresh_token', '')
expires_in = int(data.get('access_token_expiration', 0) or 0)
if not access_token:
raise ValidationError(_("Clover OAuth response did not contain an access_token."))
vals = {
'clover_oauth_access_token': access_token,
'clover_oauth_refresh_token': refresh_token or False,
}
if expires_in:
# Clover returns the expiry as a UNIX timestamp in seconds, not
# a duration. Detect both shapes (a duration is < ~10 years).
if expires_in > 10 * 365 * 24 * 3600:
vals['clover_oauth_token_expiry'] = datetime.utcfromtimestamp(expires_in)
else:
vals['clover_oauth_token_expiry'] = (
datetime.utcnow() + timedelta(seconds=expires_in)
)
self.sudo().write(vals)
return True
# === BUSINESS METHODS - API REQUESTS === #
def _clover_make_ecom_request(self, method, endpoint, payload=None, params=None):
def _clover_make_ecom_request(self, method, endpoint, payload=None, params=None, _retry=True):
"""Make an authenticated API request to the Clover Ecommerce API.
:param str method: HTTP method (GET, POST, PUT, DELETE).
@@ -109,8 +423,16 @@ class PaymentProvider(models.Model):
url = clover_utils.build_ecom_url(endpoint, is_test=is_test)
idempotency_key = clover_utils.generate_idempotency_key()
# Auth precedence: OAuth access_token (works for both Ecommerce
# AND Platform APIs per Clover docs) > Ecommerce private token.
# Routed through _clover_get_platform_token() so the OAuth token
# is proactively refreshed if it's near expiry.
if self.clover_oauth_access_token:
ecom_token = self._clover_get_platform_token()
else:
ecom_token = self.clover_api_key or ''
headers = clover_utils.build_ecom_headers(
self.clover_api_key, idempotency_key=idempotency_key,
ecom_token, idempotency_key=idempotency_key,
)
_logger.info(
@@ -142,6 +464,13 @@ class PaymentProvider(models.Model):
_logger.error("Clover returned non-JSON response: %s", response.text[:500])
raise ValidationError(_("Clover returned an invalid response."))
if response.status_code == 401 and _retry and self.clover_oauth_refresh_token:
_logger.info("Clover Ecom 401, attempting token refresh + retry")
if self._clover_refresh_oauth_token():
return self._clover_make_ecom_request(
method, endpoint, payload=payload, params=params, _retry=False,
)
if response.status_code >= 400:
error = result.get('error', {})
error_msg = error.get('message', '') if isinstance(error, dict) else str(error)
@@ -161,7 +490,7 @@ class PaymentProvider(models.Model):
return result
def _clover_make_platform_request(self, method, endpoint, payload=None, params=None):
def _clover_make_platform_request(self, method, endpoint, payload=None, params=None, _retry=True):
"""Make an authenticated request to the Clover Platform API.
:param str method: HTTP method.
@@ -179,8 +508,8 @@ class PaymentProvider(models.Model):
endpoint, merchant_id=self.clover_merchant_id, is_test=is_test,
)
# Platform API uses the REST API token, falling back to ecom key
api_token = self.clover_rest_api_token or self.clover_api_key
# Platform API auth precedence: OAuth > merchant REST API token > ecom key
api_token = self._clover_get_platform_token()
headers = clover_utils.build_ecom_headers(api_token)
_logger.info("Clover Platform API %s request to %s", method, url)
@@ -208,6 +537,13 @@ class PaymentProvider(models.Model):
return {}
raise ValidationError(_("Clover returned an invalid response."))
if response.status_code == 401 and _retry and self.clover_oauth_refresh_token:
_logger.info("Clover Platform 401, attempting token refresh + retry")
if self._clover_refresh_oauth_token():
return self._clover_make_platform_request(
method, endpoint, payload=payload, params=params, _retry=False,
)
if response.status_code >= 400:
error_msg = result.get('message', result.get('error', 'Unknown error'))
raise ValidationError(
@@ -217,6 +553,122 @@ class PaymentProvider(models.Model):
return result
# === BUSINESS METHODS - TOKENIZATION === #
@staticmethod
def _clover_detect_brand_from_pan(pan):
"""Detect the card brand from a digits-only PAN, returning one of
Clover's accepted brand codes (VISA, MC, AMEX, DISCOVER, DINERS,
JCB). Falls back to VISA if unrecognised — Clover will then
decline at the charge step rather than the tokenize step, which
is a slightly more useful error path."""
if not pan or len(pan) < 2:
return 'VISA'
if pan[:2] in ('34', '37'):
return 'AMEX'
if pan[0] == '4':
return 'VISA'
try:
p2 = int(pan[:2])
p4 = int(pan[:4]) if len(pan) >= 4 else 0
except ValueError:
return 'VISA'
if 51 <= p2 <= 55:
return 'MC'
if 2221 <= p4 <= 2720:
return 'MC'
if pan[:4] == '6011' or pan[:2] == '65':
return 'DISCOVER'
if pan[:4] in ('3014', '3036', '3038') or pan[:2] in ('30', '36', '38'):
return 'DINERS'
if pan[:2] == '35':
return 'JCB'
return 'VISA'
def _clover_tokenize_card(self, card_number, exp_month, exp_year, cvv,
cardholder_name='', postal_code=''):
"""Server-side card tokenization via Clover's tokenization service.
Used by the back-office wizard for staff-keyed card entry. Returns
a ``clv_xxx`` token that can then be passed to ``/v1/charges`` as
the ``source``. The raw PAN is sent ONCE to Clover's tokenization
endpoint and never persisted in Odoo.
Note: Westin Healthcare staff should normally use the terminal
flow rather than manual card entry, which keeps even this brief
in-memory PAN handling out of the picture.
:param str card_number: Card number, digits only.
:param int exp_month: Expiry month (1-12).
:param int exp_year: Expiry year (4-digit).
:param str cvv: Card verification value.
:param str cardholder_name: Optional name on card.
:param str postal_code: Optional billing postal code.
:return: The Clover token string ``clv_xxx``.
:rtype: str
:raises ValidationError: If tokenization fails.
"""
self.ensure_one()
is_test = self.state == 'test'
base_url = const.TOKEN_BASE_URL_TEST if is_test else const.TOKEN_BASE_URL
url = f"{base_url}/v1/tokens"
if not self.clover_public_key:
raise ValidationError(_(
"No Clover Public API Key (PAKMS) configured. Add it on "
"the payment provider record before staff can charge cards "
"from the back-office wizard."
))
# Clover's tokenization endpoint requires the lowercase `apikey`
# header (NOT the `apiAccessKey` field name returned by /pakms).
# https://docs.clover.com/dev/reference/create-card-token
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'apikey': self.clover_public_key,
}
# Detect card brand from the BIN. Clover requires a real brand
# name (VISA/MC/AMEX/DISCOVER/DINERS/JCB) — `CARD` is not valid.
clean_pan = str(card_number).replace(' ', '').replace('-', '')
brand = self._clover_detect_brand_from_pan(clean_pan)
payload = {
'card': {
'number': clean_pan,
'exp_month': str(exp_month).zfill(2),
'exp_year': str(exp_year),
'cvv': str(cvv),
'brand': brand,
},
}
if cardholder_name:
payload['card']['name'] = cardholder_name
if postal_code:
payload['card']['address_zip'] = postal_code
try:
response = requests.post(url, json=payload, headers=headers, timeout=30)
except requests.exceptions.RequestException as e:
raise ValidationError(_("Could not reach Clover tokenization service: %s", e))
try:
result = response.json()
except ValueError:
raise ValidationError(_("Clover returned an invalid tokenization response."))
if response.status_code >= 400 or 'id' not in result:
error = result.get('error', {})
error_msg = error.get('message', '') if isinstance(error, dict) else str(error)
raise ValidationError(
_("Clover tokenization failed (%(code)s): %(msg)s",
code=response.status_code, msg=error_msg or 'Unknown error')
)
token = result['id']
if not token.startswith('clv_'):
raise ValidationError(_("Unexpected token format from Clover: %s", token))
return token
# === BUSINESS METHODS - CHARGE / TOKENIZE === #
def _clover_create_charge(self, source_token, amount, currency,
@@ -360,9 +812,19 @@ class PaymentProvider(models.Model):
"""
self.ensure_one()
partner = self.env['res.partner'].browse(partner_id).exists()
partner = self.env['res.partner'].browse(int(partner_id)).exists() if partner_id else self.env['res.partner']
minor_amount = clover_utils.format_clover_amount(amount, currency) if amount else 0
# Map the Odoo language to a Clover-supported locale (Clover only
# supports en-US, en-CA, fr-CA today). Anything else falls back to
# en-US (Clover SDK default).
partner_lang = (partner.lang or self.env.user.lang or 'en_US').replace('_', '-')
clover_locale = ''
if partner_lang.startswith('fr'):
clover_locale = 'fr-CA'
elif partner_lang in ('en-CA',):
clover_locale = 'en-CA'
inline_form_values = {
'provider_id': self.id,
'merchant_id': self.clover_merchant_id,
@@ -371,6 +833,7 @@ class PaymentProvider(models.Model):
'minor_amount': minor_amount,
'capture_method': 'manual' if self.capture_manually else 'automatic',
'is_test': self.state == 'test',
'locale': clover_locale,
'billing_details': {
'name': partner.name or '',
'email': partner.email or '',
@@ -411,7 +874,7 @@ class PaymentProvider(models.Model):
# === BUSINESS METHODS - TERMINAL (REST Pay Display Cloud API) === #
def _clover_terminal_request(self, method, endpoint, serial_number=None,
payload=None, params=None):
payload=None, params=None, _retry=True):
"""Make a request to the Clover REST Pay Display Cloud API.
Sends commands to Clover terminals through Clover's cloud (Cloud Pay Display).
@@ -434,8 +897,12 @@ class PaymentProvider(models.Model):
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': f'Bearer {self.clover_rest_api_token or self.clover_api_key}',
'X-POS-ID': 'FusionCloverOdoo',
'Authorization': f'Bearer {self._clover_get_platform_token()}',
# X-POS-Id MUST be the Remote App ID (RAID) from the Clover
# developer dashboard (App Type > Web > "is integration of an
# existing POS" = Yes). Falls back to a static identifier in
# sandbox where some merchants use the legacy free-form value.
'X-POS-Id': self.clover_remote_app_id or 'FusionCloverOdoo',
}
if serial_number:
headers['X-Clover-Device-Id'] = serial_number
@@ -472,6 +939,14 @@ class PaymentProvider(models.Model):
_logger.error("Clover Terminal returned non-JSON: %s", response.text[:500])
raise ValidationError(_("Clover terminal returned an invalid response."))
if response.status_code == 401 and _retry and self.clover_oauth_refresh_token:
_logger.info("Clover Terminal 401, attempting token refresh + retry")
if self._clover_refresh_oauth_token():
return self._clover_terminal_request(
method, endpoint, serial_number=serial_number,
payload=payload, params=params, _retry=False,
)
if response.status_code >= 400:
error_msg = result.get('message', result.get('error', 'Unknown error'))
_logger.error(

View File

@@ -442,6 +442,51 @@ class PaymentTransaction(models.Model):
return tx
def _extract_amount_data(self, payment_data):
"""Override of `payment` for the Odoo 19 amount-tamper check.
Odoo's ``_validate_amount`` calls this and KErrors if the dict
doesn't contain ``amount`` + ``currency_code``. Other providers
always include these in their ``payment_data``; we historically
didn't. Extract from the Clover charge response shape:
- source token charges (Ecommerce):
``{'amount': <cents>, 'currency': 'usd', ...}`` (raw Clover
response usually shoved into ``payment_data['raw']``)
- terminal payments: ``payment_data['amount']`` (we set this
explicitly when we build the dict)
Returns ``None`` to opt out of the amount check when no usable
amount field is present — the alternative (hard-failing the
transaction) is worse than skipping validation, and our charge
amounts are server-controlled (we send them; Clover doesn't
invent new amounts).
"""
if self.provider_code != 'clover':
return super()._extract_amount_data(payment_data)
amount_minor = payment_data.get('amount')
currency_code = payment_data.get('currency')
# Some code paths put the raw Clover response under 'raw' or pass
# the whole charge dict — try those fallbacks before opting out.
if amount_minor is None and isinstance(payment_data.get('raw'), dict):
amount_minor = payment_data['raw'].get('amount')
currency_code = payment_data['raw'].get('currency') or currency_code
if amount_minor is None or not currency_code:
return None # opt out of amount validation (server-controlled)
# Clover amounts are in minor units (cents for USD/CAD, no
# decimals for JPY/KRW). Convert back to major units using the
# transaction's currency to know the divisor.
from odoo.addons.fusion_clover import utils as clover_utils
amount_major = clover_utils.parse_clover_amount(
int(amount_minor), self.currency_id,
)
return {
'amount': float(amount_major),
'currency_code': str(currency_code).upper(),
}
def _apply_updates(self, payment_data):
"""Override of `payment` to update the transaction based on Clover data."""
if self.provider_code != 'clover':

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -1,33 +1,40 @@
/** @odoo-module **/
import { _t } from '@web/core/l10n/translation';
import { patch } from '@web/core/utils/patch';
import { rpc } from '@web/core/network/rpc';
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
import { rpc } from "@web/core/network/rpc";
import { loadJS } from "@web/core/assets";
import { PaymentForm } from '@payment/interactions/payment_form';
import { PaymentForm } from "@payment/interactions/payment_form";
const CLOVER_SDK_URL_TEST = "https://checkout.sandbox.dev.clover.com/sdk.js";
const CLOVER_SDK_URL_PROD = "https://checkout.clover.com/sdk.js";
patch(PaymentForm.prototype, {
setup() {
super.setup();
this.cloverFormData = {};
this._detectedCardType = 'other';
this._selectedCardType = 'other';
this.cloverInstance = null;
this.cloverElements = null;
this.cloverMountedElements = {};
this._detectedCardType = "other";
this._selectedCardType = "other";
},
// #=== DOM MANIPULATION ===#
async _prepareInlineForm(providerId, providerCode, paymentOptionId, paymentMethodCode, flow) {
if (providerCode !== 'clover') {
if (providerCode !== "clover") {
await super._prepareInlineForm(...arguments);
return;
}
if (flow === 'token') {
if (flow === "token") {
return;
}
this._setPaymentFlow('direct');
this._setPaymentFlow("direct");
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
const inlineForm = this._getInlineForm(radio);
@@ -37,45 +44,135 @@ patch(PaymentForm.prototype, {
return;
}
const rawValues = cloverContainer.dataset['cloverInlineFormValues'];
const rawValues = cloverContainer.dataset["cloverInlineFormValues"];
if (rawValues) {
this.cloverFormData = JSON.parse(rawValues);
}
this._setupCardFormatting(cloverContainer);
this._setupTerminalToggle(cloverContainer);
this._setupSurcharge(cloverContainer);
try {
await this._loadCloverSDK(this.cloverFormData.is_test);
this._initialiseCloverIframe(cloverContainer);
} catch (error) {
this._showCloverSdkError(
cloverContainer,
error?.message || _t("Could not initialise the Clover payment form."),
);
}
},
_detectCardBrand(number) {
const num = (number || '').replace(/\D/g, '');
if (num.length < 2) return 'other';
const prefix2 = num.substring(0, 2);
if (prefix2 === '34' || prefix2 === '37') return 'amex';
if (num[0] === '4') return 'visa';
const p2 = parseInt(prefix2, 10);
if (p2 >= 51 && p2 <= 55) return 'mastercard';
if (num.length >= 4) {
const p4 = parseInt(num.substring(0, 4), 10);
if (p4 >= 2221 && p4 <= 2720) return 'mastercard';
async _loadCloverSDK(isTest) {
const url = isTest ? CLOVER_SDK_URL_TEST : CLOVER_SDK_URL_PROD;
await loadJS(url);
if (typeof window.Clover === "undefined") {
throw new Error(_t("Clover SDK failed to load."));
}
return 'other';
},
_initialiseCloverIframe(container) {
const data = this.cloverFormData;
const apiAccessKey = data.public_key;
const merchantId = data.merchant_id;
if (!apiAccessKey) {
this._showCloverSdkError(
container,
_t("This Clover provider has no Public API Key (PAKMS) configured. Add it on the payment provider record before customers can pay online."),
);
return;
}
const cloverConfig = { merchantId };
if (data.locale) {
cloverConfig.locale = data.locale;
}
// eslint-disable-next-line no-undef
this.cloverInstance = new Clover(apiAccessKey, cloverConfig);
this.cloverElements = this.cloverInstance.elements();
const styles = {
input: {
fontSize: "16px",
fontFamily: "inherit",
color: "#212529",
padding: "10px 12px",
},
".invalid": { color: "#dc3545" },
};
const mounts = [
["CARD_NUMBER", "#clover-card-number", "#clover-card-number-errors"],
["CARD_DATE", "#clover-card-date", "#clover-card-date-errors"],
["CARD_CVV", "#clover-card-cvv", "#clover-card-cvv-errors"],
["CARD_POSTAL_CODE", "#clover-card-postal-code", "#clover-card-postal-code-errors"],
];
for (const [type, mountSelector, errorSelector] of mounts) {
const element = this.cloverElements.create(type, styles);
element.mount(mountSelector);
element.addEventListener("change", (event) => {
const errorEl = container.querySelector(errorSelector);
if (!errorEl) return;
if (event && event[type] && event[type].error) {
errorEl.textContent = event[type].error;
} else {
errorEl.textContent = "";
}
if (type === "CARD_NUMBER" && event && event.CARD_NUMBER) {
const brand = (event.CARD_NUMBER.brand || "").toLowerCase();
if (brand) {
const mapped = this._mapCloverBrandToOdoo(brand);
if (mapped !== this._detectedCardType) {
this._detectedCardType = mapped;
if (mapped !== "other") {
this._selectedCardType = mapped;
}
this._updateSurchargeDisplay(container);
}
}
}
});
this.cloverMountedElements[type] = element;
}
},
_mapCloverBrandToOdoo(brand) {
const map = {
visa: "visa",
mastercard: "mastercard",
mc: "mastercard",
amex: "amex",
"american_express": "amex",
discover: "other",
"diners_club": "other",
jcb: "other",
unionpay: "other",
unknown: "other",
};
return map[brand] || "other";
},
_showCloverSdkError(container, message) {
const banner = container.querySelector("#clover-sdk-error");
const messageEl = container.querySelector("#clover-sdk-error-message");
if (banner) banner.classList.remove("d-none");
if (messageEl) messageEl.textContent = message;
},
_setupSurcharge(container) {
const surchargeConfig = this.cloverFormData.surcharge;
if (!surchargeConfig || !surchargeConfig.enabled) return;
const cardTypeSection = container.querySelector('.o_clover_card_type_section');
const surchargeNotice = container.querySelector('.o_clover_surcharge_notice');
const cardTypeSection = container.querySelector(".o_clover_card_type_section");
if (cardTypeSection) {
cardTypeSection.style.display = 'block';
cardTypeSection.style.display = "block";
}
const cardTypeRadios = container.querySelectorAll('input[name="clover_card_type"]');
cardTypeRadios.forEach(radio => {
radio.addEventListener('change', () => {
cardTypeRadios.forEach((radio) => {
radio.addEventListener("change", () => {
this._selectedCardType = radio.value;
this._updateSurchargeDisplay(container);
});
@@ -88,137 +185,82 @@ patch(PaymentForm.prototype, {
const surchargeConfig = this.cloverFormData.surcharge;
if (!surchargeConfig || !surchargeConfig.enabled) return;
const cardType = this._detectedCardType !== 'other'
const cardType = this._detectedCardType !== "other"
? this._detectedCardType
: this._selectedCardType;
const rate = surchargeConfig[cardType] || surchargeConfig['other'] || 0;
const amount = this.cloverFormData.minor_amount || 0;
const rate = surchargeConfig[cardType] || surchargeConfig["other"] || 0;
const minorAmount = this.cloverFormData.minor_amount || 0;
const baseAmount = amount / 100;
const baseAmount = minorAmount / 100;
const feeAmount = Math.round(baseAmount * rate) / 100;
const rateEl = container.querySelector('#clover_surcharge_rate');
const amountEl = container.querySelector('#clover_surcharge_amount');
const noticeEl = container.querySelector('.o_clover_surcharge_notice');
const rateEl = container.querySelector("#clover_surcharge_rate");
const amountEl = container.querySelector("#clover_surcharge_amount");
const noticeEl = container.querySelector(".o_clover_surcharge_notice");
if (rateEl) rateEl.textContent = rate.toFixed(2);
if (amountEl) amountEl.textContent = `$${feeAmount.toFixed(2)}`;
if (noticeEl) {
noticeEl.style.display = rate > 0 ? 'block' : 'none';
noticeEl.style.display = rate > 0 ? "block" : "none";
}
const radioToCheck = container.querySelector(
`input[name="clover_card_type"][value="${cardType}"]`
`input[name="clover_card_type"][value="${cardType}"]`,
);
if (radioToCheck && !radioToCheck.checked) {
radioToCheck.checked = true;
}
},
_setupCardFormatting(container) {
const cardInput = container.querySelector('#clover_card_number');
if (cardInput) {
cardInput.addEventListener('input', (e) => {
let value = e.target.value.replace(/\D/g, '');
let formatted = '';
for (let i = 0; i < value.length && i < 16; i++) {
if (i > 0 && i % 4 === 0) {
formatted += ' ';
}
formatted += value[i];
}
e.target.value = formatted;
const detected = this._detectCardBrand(value);
if (detected !== this._detectedCardType) {
this._detectedCardType = detected;
if (detected !== 'other') {
this._selectedCardType = detected;
}
this._updateSurchargeDisplay(
e.target.closest('.o_clover_payment_form')
);
}
});
}
const expiryInput = container.querySelector('#clover_expiry');
if (expiryInput) {
expiryInput.addEventListener('input', (e) => {
let value = e.target.value.replace(/\D/g, '');
if (value.length >= 2) {
value = value.substring(0, 2) + '/' + value.substring(2, 4);
}
e.target.value = value;
});
}
},
_setupTerminalToggle(container) {
const terminalCheckbox = container.querySelector('#clover_use_terminal');
const terminalSelect = container.querySelector('#clover_terminal_select_wrapper');
const cardFields = container.querySelectorAll(
'#clover_card_number, #clover_expiry, #clover_cvv, #clover_cardholder'
);
const terminalCheckbox = container.querySelector("#clover_use_terminal");
const terminalSelect = container.querySelector("#clover_terminal_select_wrapper");
const iframeForm = container.querySelector(".o_clover_iframe_form");
if (!terminalCheckbox) {
return;
}
terminalCheckbox.addEventListener('change', () => {
terminalCheckbox.addEventListener("change", () => {
if (terminalCheckbox.checked) {
if (terminalSelect) {
terminalSelect.style.display = 'block';
}
cardFields.forEach(f => {
f.closest('.mb-3').style.display = 'none';
f.removeAttribute('required');
});
if (terminalSelect) terminalSelect.style.display = "block";
if (iframeForm) iframeForm.style.display = "none";
this._loadTerminals(container);
} else {
if (terminalSelect) {
terminalSelect.style.display = 'none';
}
cardFields.forEach(f => {
f.closest('.mb-3').style.display = 'block';
if (f.id !== 'clover_cardholder') {
f.setAttribute('required', 'required');
}
});
if (terminalSelect) terminalSelect.style.display = "none";
if (iframeForm) iframeForm.style.display = "block";
}
});
},
async _loadTerminals(container) {
const selectEl = container.querySelector('#clover_terminal_select');
const selectEl = container.querySelector("#clover_terminal_select");
if (!selectEl || selectEl.options.length > 1) {
return;
}
try {
const terminals = await rpc('/payment/clover/terminals', {
const terminals = await rpc("/payment/clover/terminals", {
provider_id: this.cloverFormData.provider_id,
});
selectEl.innerHTML = '';
selectEl.innerHTML = "";
if (terminals && terminals.length > 0) {
terminals.forEach(t => {
const option = document.createElement('option');
terminals.forEach((t) => {
const option = document.createElement("option");
option.value = t.id;
option.textContent = `${t.name} (${t.status})`;
selectEl.appendChild(option);
});
} else {
const option = document.createElement('option');
option.value = '';
option.textContent = _t('No terminals available');
const option = document.createElement("option");
option.value = "";
option.textContent = _t("No terminals available");
selectEl.appendChild(option);
}
} catch {
const option = document.createElement('option');
option.value = '';
option.textContent = _t('Failed to load terminals');
const option = document.createElement("option");
option.value = "";
option.textContent = _t("Failed to load terminals");
selectEl.appendChild(option);
}
},
@@ -226,18 +268,18 @@ patch(PaymentForm.prototype, {
// #=== PAYMENT FLOW ===#
async _initiatePaymentFlow(providerCode, paymentOptionId, paymentMethodCode, flow) {
if (providerCode !== 'clover' || flow === 'token') {
if (providerCode !== "clover" || flow === "token") {
await super._initiatePaymentFlow(...arguments);
return;
}
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
const inlineForm = this._getInlineForm(radio);
const useTerminal = inlineForm.querySelector('#clover_use_terminal');
const useTerminal = inlineForm.querySelector("#clover_use_terminal");
if (useTerminal && useTerminal.checked) {
const terminalId = inlineForm.querySelector('#clover_terminal_select').value;
if (!terminalId) {
const terminalSelect = inlineForm.querySelector("#clover_terminal_select");
if (!terminalSelect || !terminalSelect.value) {
this._displayErrorDialog(
_t("Terminal Required"),
_t("Please select a terminal device."),
@@ -245,64 +287,27 @@ patch(PaymentForm.prototype, {
this._enableButton();
return;
}
} else {
const validationError = this._validateCardInputs(inlineForm);
if (validationError) {
this._displayErrorDialog(
_t("Invalid Card Details"),
validationError,
);
this._enableButton();
return;
}
} else if (!this.cloverInstance) {
this._displayErrorDialog(
_t("Payment form not ready"),
_t("The Clover payment form has not finished loading. Please wait a moment and try again."),
);
this._enableButton();
return;
}
await super._initiatePaymentFlow(...arguments);
},
_validateCardInputs(inlineForm) {
const cardNumber = inlineForm.querySelector('#clover_card_number');
const expiry = inlineForm.querySelector('#clover_expiry');
const cvv = inlineForm.querySelector('#clover_cvv');
const cardDigits = cardNumber.value.replace(/\D/g, '');
if (cardDigits.length < 13 || cardDigits.length > 19) {
return _t("Please enter a valid card number.");
}
const expiryValue = expiry.value;
if (!/^\d{2}\/\d{2}$/.test(expiryValue)) {
return _t("Please enter a valid expiry date (MM/YY).");
}
const [month, year] = expiryValue.split('/').map(Number);
if (month < 1 || month > 12) {
return _t("Invalid expiry month.");
}
const now = new Date();
const expiryDate = new Date(2000 + year, month);
if (expiryDate <= now) {
return _t("Card has expired.");
}
const cvvValue = cvv.value.replace(/\D/g, '');
if (cvvValue.length < 3 || cvvValue.length > 4) {
return _t("Please enter a valid CVV.");
}
return null;
},
async _processDirectFlow(providerCode, paymentOptionId, paymentMethodCode, processingValues) {
if (providerCode !== 'clover') {
if (providerCode !== "clover") {
await super._processDirectFlow(...arguments);
return;
}
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
const inlineForm = this._getInlineForm(radio);
const useTerminal = inlineForm.querySelector('#clover_use_terminal');
const useTerminal = inlineForm.querySelector("#clover_use_terminal");
if (useTerminal && useTerminal.checked) {
await this._processTerminalPayment(processingValues, inlineForm);
@@ -313,36 +318,73 @@ patch(PaymentForm.prototype, {
_getSelectedCardType(inlineForm) {
const checked = inlineForm.querySelector('input[name="clover_card_type"]:checked');
return checked ? checked.value : 'other';
return checked ? checked.value : "other";
},
async _processCardPayment(processingValues, inlineForm) {
const cardNumber = inlineForm.querySelector('#clover_card_number').value.replace(/\D/g, '');
const expiry = inlineForm.querySelector('#clover_expiry').value;
const cvv = inlineForm.querySelector('#clover_cvv').value;
const cardholder = inlineForm.querySelector('#clover_cardholder').value;
const cardType = this._detectedCardType !== 'other'
if (!this.cloverInstance) {
this._displayErrorDialog(
_t("Payment form error"),
_t("Clover SDK has not been initialised. Please reload the page."),
);
this._enableButton();
return;
}
const cardType = this._detectedCardType !== "other"
? this._detectedCardType
: this._getSelectedCardType(inlineForm);
const [expMonth, expYear] = expiry.split('/').map(Number);
let result;
try {
result = await this.cloverInstance.createToken();
} catch (error) {
this._displayErrorDialog(
_t("Card validation error"),
error?.message || _t("Could not validate the card details."),
);
this._enableButton();
return;
}
if (!result || result.errors) {
const messages = [];
if (result && result.errors) {
Object.values(result.errors).forEach((value) => {
if (typeof value === "string") {
messages.push(value);
} else if (value && value.error) {
messages.push(value.error);
}
});
}
this._displayErrorDialog(
_t("Invalid Card Details"),
messages.join(" ") || _t("Please check the card details and try again."),
);
this._enableButton();
return;
}
const token = result.token;
if (!token) {
this._displayErrorDialog(
_t("Tokenization Failed"),
_t("Clover did not return a card token. Please try again."),
);
this._enableButton();
return;
}
try {
const result = await rpc('/payment/clover/process_card', {
const response = await rpc("/payment/clover/process_card", {
reference: processingValues.reference,
card_number: cardNumber,
exp_month: expMonth,
exp_year: 2000 + expYear,
cvv: cvv,
cardholder_name: cardholder,
card_token: token,
card_type: cardType,
});
if (result.error) {
this._displayErrorDialog(
_t("Payment Failed"),
result.error,
);
if (response.error) {
this._displayErrorDialog(_t("Payment Failed"), response.error);
this._enableButton();
return;
}
@@ -358,25 +400,19 @@ patch(PaymentForm.prototype, {
},
async _processTerminalPayment(processingValues, inlineForm) {
const terminalId = inlineForm.querySelector('#clover_terminal_select').value;
const terminalId = inlineForm.querySelector("#clover_terminal_select").value;
const cardType = this._getSelectedCardType(inlineForm);
try {
const result = await rpc('/payment/clover/send_to_terminal', {
const result = await rpc("/payment/clover/send_to_terminal", {
reference: processingValues.reference,
terminal_id: parseInt(terminalId),
card_type: cardType,
});
if (result.error) {
this._displayErrorDialog(
_t("Terminal Payment Failed"),
result.error,
);
this._displayErrorDialog(_t("Terminal Payment Failed"), result.error);
this._enableButton();
return;
}
this._showTerminalWaitingScreen(processingValues, terminalId);
} catch (error) {
this._displayErrorDialog(
@@ -388,7 +424,7 @@ patch(PaymentForm.prototype, {
},
_showTerminalWaitingScreen(processingValues, terminalId) {
const container = document.querySelector('.o_clover_payment_form');
const container = document.querySelector(".o_clover_payment_form");
if (container) {
container.innerHTML = `
<div class="text-center p-4">
@@ -405,14 +441,12 @@ patch(PaymentForm.prototype, {
</div>
`;
}
this._pollTerminalStatus(processingValues, terminalId);
},
async _pollTerminalStatus(processingValues, terminalId, attempt = 0) {
const maxAttempts = 60;
const pollInterval = 3000;
if (attempt >= maxAttempts) {
this._displayErrorDialog(
_t("Timeout"),
@@ -421,26 +455,26 @@ patch(PaymentForm.prototype, {
this._enableButton();
return;
}
try {
const result = await rpc('/payment/clover/terminal_status', {
const result = await rpc("/payment/clover/terminal_status", {
reference: processingValues.reference,
terminal_id: parseInt(terminalId),
});
const statusEl = document.getElementById('clover_terminal_status');
if (result.status === 'CLOSED' || result.status === 'CAPTURED'
|| result.status === 'AUTH' || result.status === 'AUTHORIZED') {
const statusEl = document.getElementById("clover_terminal_status");
if (
result.status === "CLOSED" || result.status === "CAPTURED"
|| result.status === "AUTH" || result.status === "AUTHORIZED"
) {
if (statusEl) {
statusEl.textContent = _t("Payment completed! Redirecting...");
}
window.location.href = processingValues.return_url;
return;
}
if (result.status === 'DECLINED' || result.status === 'FAILED'
|| result.status === 'FAIL') {
if (
result.status === "DECLINED" || result.status === "FAILED"
|| result.status === "FAIL"
) {
this._displayErrorDialog(
_t("Payment Declined"),
_t("The payment was declined at the terminal."),
@@ -448,11 +482,9 @@ patch(PaymentForm.prototype, {
this._enableButton();
return;
}
if (statusEl) {
statusEl.textContent = _t("Status: ") + (result.status || _t("Pending"));
}
setTimeout(
() => this._pollTerminalStatus(processingValues, terminalId, attempt + 1),
pollInterval,

View File

@@ -155,23 +155,45 @@ def build_charge_payload(amount, currency, source_token, capture=True,
def build_refund_payload(charge_id, amount=None, currency=None, reason=''):
"""Build a Clover refund payload.
"""Build a Clover ``/v1/refunds`` payload.
The Clover Ecommerce ``/v1/refunds`` endpoint accepts ONLY the
``charge`` field — including any other field triggers a 400
"Invalid JSON format" response. ``amount`` and ``reason`` arguments
are accepted for backwards-compat but ignored by this endpoint.
For partial refunds, callers should use the
``/v1/payments/{paymentId}/refunds`` endpoint instead, which takes
``{"amount": <cents>}`` or ``{"fullRefund": true}``.
https://docs.clover.com/dev/docs/ecommerce-refunding-payments
:param str charge_id: The Clover charge ID to refund.
:param float amount: Optional partial refund amount in major currency units.
:param recordset currency: Optional currency record (needed for partial refunds).
:param str reason: Optional reason for the refund.
:param float amount: IGNORED on this endpoint (full refund only).
Use a /payments/{id}/refunds call for partial.
:param recordset currency: IGNORED.
:param str reason: IGNORED on this endpoint.
:return: The Clover-formatted refund payload.
:rtype: dict
"""
payload = {
'charge': charge_id,
}
return {'charge': charge_id}
def build_payment_refund_payload(amount=None, currency=None, full_refund=False):
"""Build a Clover ``/v1/payments/{paymentId}/refunds`` payload.
Used for partial refunds (the ``/v1/refunds`` endpoint is full-refund
only). Either pass ``full_refund=True`` to refund the entire payment,
or ``amount`` (with ``currency``) for a partial.
:param float amount: Partial refund amount in major currency units.
:param recordset currency: Currency record for the partial refund.
:param bool full_refund: If True, refund the entire payment.
:return: The Clover-formatted payment-refund payload.
:rtype: dict
"""
if full_refund:
return {'fullRefund': True}
if amount is not None and currency:
payload['amount'] = format_clover_amount(amount, currency)
if reason:
payload['reason'] = reason
return payload
return {'amount': format_clover_amount(amount, currency)}
return {'fullRefund': True}

View File

@@ -100,7 +100,7 @@
name="action_sync_terminals"
class="btn-secondary"
icon="fa-refresh"
invisible="not clover_merchant_id or (not clover_rest_api_token and not clover_api_key)"
invisible="not clover_merchant_id or (not clover_rest_api_token and not clover_api_key and not clover_oauth_access_token)"
colspan="2"/>
</xpath>
</field>

View File

@@ -1,7 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Inline payment form template for Clover -->
<!-- Inline payment form template for Clover.
The card data fields are RENDERED BY CLOVER in iframes mounted
into the empty <div id="clover-card-*"> containers below. Card
data NEVER touches Odoo - the Clover iframe SDK posts directly
to Clover's tokenization endpoint and returns a clv_xxx token
which is what we then send to the backend. This is the only
way to keep Westin / NEXA out of PCI SAQ-D scope. -->
<template id="inline_form">
<t t-set="inline_form_values"
t-value="provider_sudo._clover_get_inline_form_values(
@@ -16,8 +23,10 @@
class="o_clover_payment_form"
t-att-data-clover-inline-form-values="inline_form_values">
<!-- Terminal toggle -->
<div class="mb-3 form-check">
<!-- Terminal toggle (back-office staff or in-person checkout) -->
<div class="mb-3 form-check"
name="o_clover_terminal_toggle_wrapper"
style="display:none;">
<input type="checkbox" class="form-check-input"
id="clover_use_terminal" name="use_terminal"/>
<label class="form-check-label" for="clover_use_terminal">
@@ -26,7 +35,6 @@
</label>
</div>
<!-- Terminal select (hidden by default) -->
<div class="mb-3" id="clover_terminal_select_wrapper" style="display:none;">
<label class="form-label" for="clover_terminal_select">Select Terminal</label>
<select class="form-select" id="clover_terminal_select" name="terminal_id">
@@ -34,53 +42,51 @@
</select>
</div>
<!-- Card number input -->
<div class="mb-3">
<label class="form-label" for="clover_card_number">Card Number</label>
<input type="text" class="form-control"
id="clover_card_number"
name="card_number"
placeholder="4111 1111 1111 1111"
maxlength="19"
autocomplete="cc-number"
required="required"/>
</div>
<!-- Expiry and CVV row -->
<div class="row mb-3">
<div class="col-6">
<label class="form-label" for="clover_expiry">Expiry Date</label>
<input type="text" class="form-control"
id="clover_expiry"
name="expiry"
placeholder="MM/YY"
maxlength="5"
autocomplete="cc-exp"
required="required"/>
<!-- Clover.js iframe mount points (card data lives inside these
iframes, never in our DOM). -->
<div class="o_clover_iframe_form" name="o_clover_iframe_form">
<div class="mb-3">
<label class="form-label" for="clover-card-number">Card Number</label>
<div id="clover-card-number" class="form-control"
style="height:42px; padding:0;"/>
<div class="invalid-feedback d-block"
id="clover-card-number-errors" role="alert"/>
</div>
<div class="col-6">
<label class="form-label" for="clover_cvv">CVV</label>
<input type="password" class="form-control"
id="clover_cvv"
name="cvv"
placeholder="123"
maxlength="4"
autocomplete="cc-csc"
required="required"/>
<div class="row mb-3">
<div class="col-6">
<label class="form-label" for="clover-card-date">Expiry (MM/YY)</label>
<div id="clover-card-date" class="form-control"
style="height:42px; padding:0;"/>
<div class="invalid-feedback d-block"
id="clover-card-date-errors" role="alert"/>
</div>
<div class="col-6">
<label class="form-label" for="clover-card-cvv">CVV</label>
<div id="clover-card-cvv" class="form-control"
style="height:42px; padding:0;"/>
<div class="invalid-feedback d-block"
id="clover-card-cvv-errors" role="alert"/>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="clover-card-postal-code">Postal Code</label>
<div id="clover-card-postal-code" class="form-control"
style="height:42px; padding:0;"/>
<div class="invalid-feedback d-block"
id="clover-card-postal-code-errors" role="alert"/>
</div>
<div class="alert alert-warning d-none"
id="clover-sdk-error" role="alert">
<i class="fa fa-exclamation-triangle me-1"/>
<span id="clover-sdk-error-message">
Could not load the Clover payment form.
</span>
</div>
</div>
<!-- Cardholder name -->
<div class="mb-3">
<label class="form-label" for="clover_cardholder">Cardholder Name</label>
<input type="text" class="form-control"
id="clover_cardholder"
name="cardholder_name"
placeholder="John Doe"
autocomplete="cc-name"/>
</div>
<!-- Card type selector -->
<!-- Card type selector (only visible if surcharge is on AND the
iframe SDK couldn't auto-detect the brand from the typed
card number, e.g. for "Other"). -->
<div class="mb-3 o_clover_card_type_section" style="display:none;">
<label class="form-label">Card Type</label>
<div class="d-flex gap-2 flex-wrap">
@@ -107,7 +113,6 @@
</div>
</div>
<!-- Surcharge notice -->
<div class="mb-3 o_clover_surcharge_notice" style="display:none;">
<div class="alert alert-info py-2 mb-0">
<small>

View File

@@ -23,24 +23,40 @@
<field name="clover_rest_api_token"
password="True"
placeholder="From Clover Dashboard: Setup > API Tokens"/>
<separator string="OAuth (Optional)"/>
<separator string="Nexa Developer App (OAuth)"/>
<field name="clover_app_id"
placeholder="App ID (for OAuth flow)"/>
placeholder="App ID from Clover Developer Dashboard"/>
<label for="clover_app_secret"/>
<div class="o_row" col="2">
<field name="clover_app_secret" password="True"/>
</div>
<field name="clover_remote_app_id"
placeholder="Remote App ID (RAID) - X-POS-Id header"/>
<separator string="OAuth Status (auto-populated by Connect to Clover)"/>
<field name="clover_oauth_access_token"
string="Access Token Status"
readonly="1"
password="True"/>
<field name="clover_oauth_token_expiry" readonly="1"/>
</group>
</group>
<group name="provider_credentials" position="after">
<group string="Clover Actions"
invisible="code != 'clover'" name="clover_actions"
col="4">
<button string="Connect to Clover"
type="object"
name="action_clover_oauth_connect"
class="btn-primary"
icon="fa-link"
invisible="not clover_app_id or not clover_app_secret"
colspan="2"
help="Redirects you to Clover to authorise this Odoo to act on the merchant's behalf. Required for terminal payments and webhooks."/>
<button string="Test Connection"
type="object"
name="action_clover_test_connection"
class="btn-primary"
invisible="not clover_merchant_id or (not clover_api_key and not clover_rest_api_token)"
class="btn-secondary"
invisible="not clover_merchant_id or (not clover_api_key and not clover_rest_api_token and not clover_oauth_access_token)"
colspan="2"/>
</group>
<group string="Terminal Settings"

View File

@@ -10,107 +10,63 @@
<app data-string="Fusion Clover" string="Fusion Clover" name="fusion_clover"
groups="fusion_clover.group_fusion_clover_admin">
<h2>Credit Card Surcharge</h2>
<div class="row mt-4 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="clover_surcharge_enabled"/>
</div>
<div class="o_setting_right_pane">
<span class="o_form_label">Credit Card Processing Fee</span>
<div class="text-muted">
Automatically add a surcharge line to invoices when collecting payment
via Clover. The fee is calculated as a percentage of the invoice total.
<block title="Credit Card Surcharge"
help="Automatically add a credit card processing fee to invoices when collecting payment via Clover.">
<setting id="fusion_clover_surcharge"
string="Enable Surcharge"
help="When enabled, a percentage-based fee line is added to the invoice before the Clover charge is created. The percentage depends on the card brand selected.">
<field name="clover_surcharge_enabled"/>
<div class="content-group" invisible="not clover_surcharge_enabled">
<div class="row mt16">
<label for="clover_surcharge_visa_rate"
string="Visa (%)"
class="col-lg-5 o_light_label"/>
<field name="clover_surcharge_visa_rate"/>
</div>
</div>
</div>
</div>
<div class="row mt-4 o_settings_container"
invisible="not clover_surcharge_enabled">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Surcharge Rates by Card Type</span>
<div class="text-muted mb-2">
Configure the processing fee percentage for each card brand.
<div class="row">
<label for="clover_surcharge_mastercard_rate"
string="Mastercard (%)"
class="col-lg-5 o_light_label"/>
<field name="clover_surcharge_mastercard_rate"/>
</div>
<div class="mt-2">
<div class="row mb-2">
<label for="clover_surcharge_visa_rate"
class="col-5 col-form-label">Visa</label>
<div class="col-4">
<field name="clover_surcharge_visa_rate" class="o_input"/>
</div>
<div class="col-1 col-form-label">%</div>
</div>
<div class="row mb-2">
<label for="clover_surcharge_mastercard_rate"
class="col-5 col-form-label">Mastercard</label>
<div class="col-4">
<field name="clover_surcharge_mastercard_rate" class="o_input"/>
</div>
<div class="col-1 col-form-label">%</div>
</div>
<div class="row mb-2">
<label for="clover_surcharge_amex_rate"
class="col-5 col-form-label">American Express</label>
<div class="col-4">
<field name="clover_surcharge_amex_rate" class="o_input"/>
</div>
<div class="col-1 col-form-label">%</div>
</div>
<div class="row mb-2">
<label for="clover_surcharge_debit_rate"
class="col-5 col-form-label">Debit</label>
<div class="col-4">
<field name="clover_surcharge_debit_rate" class="o_input"/>
</div>
<div class="col-1 col-form-label">%</div>
</div>
<div class="row mb-2">
<label for="clover_surcharge_other_rate"
class="col-5 col-form-label">Other Cards</label>
<div class="col-4">
<field name="clover_surcharge_other_rate" class="o_input"/>
</div>
<div class="col-1 col-form-label">%</div>
</div>
<div class="row">
<label for="clover_surcharge_amex_rate"
string="Amex (%)"
class="col-lg-5 o_light_label"/>
<field name="clover_surcharge_amex_rate"/>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Surcharge Product</span>
<div class="text-muted mb-2">
The service product used for the processing fee invoice line.
<div class="row">
<label for="clover_surcharge_debit_rate"
string="Debit (%)"
class="col-lg-5 o_light_label"/>
<field name="clover_surcharge_debit_rate"/>
</div>
<div class="mt-2">
<div class="row">
<label for="clover_surcharge_other_rate"
string="Other (%)"
class="col-lg-5 o_light_label"/>
<field name="clover_surcharge_other_rate"/>
</div>
<div class="row mt16">
<label for="clover_surcharge_product_id"
string="Fee Product"
class="col-lg-5 o_light_label"/>
<field name="clover_surcharge_product_id"
domain="[('type', '=', 'service')]"/>
domain="[('type', '=', 'service')]"
options="{'no_create': True, 'no_open': True}"/>
</div>
</div>
</div>
</div>
<h2>Quick Links</h2>
<div class="row mt-4 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Payment Provider</span>
<div class="text-muted mb-2">
Configure your Clover API credentials and merchant ID.
</div>
<div class="mt-2">
<button name="action_open_clover_provider"
type="object"
string="Configure Payment Provider"
class="btn-link"
icon="fa-arrow-right"/>
</div>
</div>
</div>
</div>
</setting>
<setting id="fusion_clover_provider_link"
string="Payment Provider"
help="Open the Clover payment provider record to configure your Merchant ID, Ecommerce API tokens and REST API token.">
<button name="action_open_clover_provider"
type="object"
string="Configure Clover"
class="btn-link"
icon="oi-arrow-right"/>
</setting>
</block>
</app>
</xpath>

View File

@@ -464,24 +464,26 @@ class CloverPaymentWizard(models.TransientModel):
provider = self._get_provider_sudo()
capture = not provider.capture_manually
minor_amount = clover_utils.format_clover_amount(
self.amount, self.currency_id,
# Tokenize the card client-server FIRST (Clover's tokenization
# endpoint), then charge using only the token. Raw PAN never
# reaches /v1/charges and is never persisted in Odoo.
card_token = provider._clover_tokenize_card(
card_number=self.card_number,
exp_month=int(self.exp_month),
exp_year=int(self.exp_year) if len(self.exp_year) == 4
else 2000 + int(self.exp_year),
cvv=self.cvv,
cardholder_name=self.cardholder_name or '',
)
payload = {
'amount': minor_amount,
'currency': self.currency_id.name.lower(),
'capture': capture,
'ecomind': 'moto',
'description': reference,
'source': self.card_number.replace(' ', ''),
'metadata': {
'odoo_reference': reference,
},
}
result = provider._clover_make_ecom_request(
'POST', 'v1/charges', payload=payload,
result = provider._clover_create_charge(
source_token=card_token,
amount=self.amount,
currency=self.currency_id,
capture=capture,
description=reference,
ecomind='moto',
metadata={'odoo_reference': reference},
)
charge_id = result.get('id', '')
@@ -497,6 +499,11 @@ class CloverPaymentWizard(models.TransientModel):
'clover_charge_id': charge_id,
'clover_status': status,
'source': result.get('source', {}),
# Pass amount + currency back so Odoo 19's amount-tamper
# check (_validate_amount) can verify Clover charged the
# exact amount we asked for.
'amount': result.get('amount'),
'currency': result.get('currency'),
}
if status == 'failed':
@@ -586,6 +593,15 @@ class CloverPaymentWizard(models.TransientModel):
raise UserError(_("Please enter a valid expiry year."))
if not self.cvv or not self.cvv.isdigit():
raise UserError(_("Please enter the CVV."))
# Clover production rejects cards without a 2-part name
# ("Firstname Lastname"). Sandbox is lenient. Validate here so the
# error message is helpful instead of a generic API 400.
if not self.cardholder_name or len(self.cardholder_name.strip().split()) < 2:
raise UserError(_(
"Please enter the cardholder's name as it appears on "
"the card (e.g. \"John Doe\"). Clover requires both "
"first and last name."
))
def _create_payment_transaction(self):
"""Create a payment.transaction linked to the invoice."""

View File

@@ -83,7 +83,8 @@
required="payment_mode == 'card' and state in ('draft', 'error')"
password="True"/>
<field name="cardholder_name"
placeholder="Name on card"/>
placeholder="Firstname Lastname"
required="payment_mode == 'card' and state in ('draft', 'error')"/>
</group>
<group>
<field name="exp_month"

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.12.5.0',
'version': '19.0.12.6.2',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """

View File

@@ -71,6 +71,8 @@ class SimpleRecipeController(http.Controller):
'requires_signoff': step.requires_signoff,
'requires_rack_assignment': step.requires_rack_assignment,
'requires_transition_form': step.requires_transition_form,
'description': step.description or '',
'notes': step.notes or '',
'tank_ids': [
{'id': t.id, 'name': t.name, 'code': t.code}
for t in step.tank_ids
@@ -160,13 +162,24 @@ class SimpleRecipeController(http.Controller):
def _sequence_for_position(self, recipe, position):
siblings = recipe.child_ids.sorted('sequence')
if not siblings or position >= len(siblings):
return (siblings[-1].sequence + 10) if siblings else 10
if not siblings:
return 10
if position >= len(siblings):
return siblings[-1].sequence + 10
if position <= 0:
return max(1, siblings[0].sequence - 10)
before = siblings[position - 1].sequence
after = siblings[position].sequence
return (before + after) // 2 if (after - before) > 1 else before + 1
if after - before > 1:
return (before + after) // 2
# Sequences are tightly packed (gap == 1 → midpoint == after,
# which collides). Renumber siblings to 10/20/30… first, then
# the new step lands cleanly between renumbered neighbours.
for idx, sib in enumerate(siblings):
new_seq = (idx + 1) * 10
if sib.sequence != new_seq:
sib.sequence = new_seq
return position * 10 + 5
def _copy_inputs_from_template(self, tpl, new_node):
NodeInput = request.env['fusion.plating.process.node.input']

View File

@@ -16,12 +16,40 @@
# cancelled (rework reverts here)
# on_hold can be entered from confirmed or in_progress.
import pytz
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpJob(models.Model):
_name = 'fp.job'
def fp_format_local(self, dt, fmt='%Y-%m-%d %H:%M'):
"""Format a UTC datetime in the viewer's local timezone.
Used by report templates: QWeb's eval scope doesn't expose pytz
or format_datetime, but record methods are always callable, so
templates do `<span t-esc="job.fp_format_local(dt, '%H:%M')"/>`.
Resolution order matches the rest of the module: env.user.tz →
company.x_fc_default_tz → UTC.
"""
if not dt:
return ''
tz_name = (
self.env.user.tz
or ('x_fc_default_tz' in self.env.company._fields
and self.env.company.x_fc_default_tz)
or 'UTC'
)
try:
tz = pytz.timezone(tz_name)
except Exception:
tz = pytz.UTC
if dt.tzinfo is None:
dt = pytz.UTC.localize(dt)
return dt.astimezone(tz).strftime(fmt)
_description = 'Plating Job'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'priority desc, date_deadline asc, id desc'

View File

@@ -168,6 +168,56 @@ class FpJobStep(models.Model):
)
qty_at_step_start = fields.Integer(string='Qty at Step Start')
qty_at_step_finish = fields.Integer(string='Qty at Step Finish')
# Live "qty currently parked at this step" — drives partial-qty
# workflows. = (incoming moves' qty outgoing moves' qty), with a
# first-step seed: the lowest-sequence step on a confirmed job
# implicitly receives the full job qty when the job starts (no
# explicit "kickoff" move record). Without that seed, the first
# step would always show 0 here until the operator manually moved
# parts in, which doesn't match how the floor thinks about it.
qty_at_step = fields.Integer(
string='Qty Here',
compute='_compute_qty_at_step',
help='Quantity currently parked at this step. Drains as moves '
'transfer parts to later steps. The Move dialog defaults '
'to this value and blocks moves above it.',
)
@api.depends('move_ids.qty_moved', 'move_ids.to_step_id',
'incoming_move_ids.qty_moved',
'incoming_move_ids.from_step_id',
'state', 'job_id.qty', 'job_id.step_ids',
'job_id.step_ids.sequence', 'sequence')
def _compute_qty_at_step(self):
for rec in self:
# Terminal states: nothing parked here anymore. Operators
# don't care if "done" steps technically have qty residue —
# surfacing zero keeps the column readable.
if rec.state in ('done', 'cancelled', 'skipped'):
rec.qty_at_step = 0
continue
# Self-loop moves (from_step == to_step, transfer_type='step')
# are how the Record Inputs wizard logs measurements; they
# don't move qty so we exclude them on both sides.
incoming = sum(
m.qty_moved for m in rec.incoming_move_ids
if m.from_step_id != rec
)
outgoing = sum(
m.qty_moved for m in rec.move_ids
if m.to_step_id != rec
)
# First-step seed: the earliest non-terminal step on a job
# implicitly receives the full job qty when the job kicks
# off (no explicit kickoff move). Without this seed, qty
# here would read 0 even when the floor has the full batch.
if not incoming and rec.job_id and rec.job_id.qty:
first_active = rec.job_id.step_ids.filtered(
lambda s: s.state not in ('done', 'cancelled', 'skipped')
).sorted('sequence')[:1]
if rec == first_active:
incoming = int(rec.job_id.qty)
rec.qty_at_step = max(0, incoming - outgoing)
@api.depends('rack_id')
def _compute_is_racked(self):
@@ -226,7 +276,7 @@ class FpJobStep(models.Model):
) % (step.name, step.state))
now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now})
open_log.write({'date_finished': now, 'state': 'paused'})
step.state = 'paused'
step.message_post(body=_('Step paused by %s') % self.env.user.name)
return True
@@ -269,7 +319,7 @@ class FpJobStep(models.Model):
) % (step.name, step.state))
now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now})
open_log.write({'date_finished': now, 'state': 'stopped'})
step.state = 'cancelled'
step.message_post(body=_('Step cancelled by %s') % self.env.user.name)
return True
@@ -305,7 +355,7 @@ class FpJobStep(models.Model):
now = fields.Datetime.now()
# Close the open timelog (the one with no date_finished)
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now})
open_log.write({'date_finished': now, 'state': 'stopped'})
step.state = 'done'
# First-finish audit (mirrors button_start first-start guard)
if not step.date_finished:

View File

@@ -13,6 +13,7 @@ import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
export class FpSimpleRecipeEditor extends Component {
@@ -37,6 +38,12 @@ export class FpSimpleRecipeEditor extends Component {
dragOverIndex: null, // 0..N (insertion index)
dragPreviewLabel: "", // shown next to the indicator line
dragPreviewIcon: "fa-cog",
// Inline edit panel — id of the step currently being edited
// (null = no panel open). Mirrors live values so the textarea
// stays controlled without RPC roundtrip on every keystroke.
editingStepId: null,
editName: "",
editInstructions: "",
});
this._recipeId = null;
@@ -90,11 +97,24 @@ export class FpSimpleRecipeEditor extends Component {
async reorderStep(stepId, newIndex) {
const ids = this.state.steps.map((s) => s.id);
const oldIndex = ids.indexOf(stepId);
if (oldIndex < 0 || oldIndex === newIndex) {
if (oldIndex < 0) {
return;
}
// dragOverIndex is the insertion point in the ORIGINAL list. Once
// we splice the dragged item out, every position to the right of
// oldIndex shifts left by one — so an insertion at newIndex when
// newIndex > oldIndex must be decremented. Without this, dropping
// right after itself moves the row one slot down instead of
// staying put.
let adjusted = newIndex;
if (newIndex > oldIndex) {
adjusted -= 1;
}
if (adjusted === oldIndex) {
return;
}
ids.splice(oldIndex, 1);
ids.splice(Math.min(newIndex, ids.length), 0, stepId);
ids.splice(Math.max(0, Math.min(adjusted, ids.length)), 0, stepId);
await rpc("/fp/simple_recipe/step/reorder", { node_ids: ids });
await this.loadAll();
}
@@ -199,6 +219,26 @@ export class FpSimpleRecipeEditor extends Component {
this.state.dragOverIndex = this.state.steps.length;
}
/**
* Panel-level dragover. Required so HTML5 `drop` actually fires
* across the whole panel surface — including the gap between rows
* (.25rem margin) and the panel padding (1rem). Without this, drops
* on those areas are silently rejected by the browser. Row-level
* dragover handlers still run first and set the precise index;
* this is the safety net that keeps the most-recently-set index
* (or end-of-list fallback) live until the user releases.
*/
onPanelDragOver(ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect =
ev.dataTransfer.types.includes("application/x-fp-library")
? "copy"
: "move";
if (this.state.dragOverIndex === null) {
this.state.dragOverIndex = this.state.steps.length;
}
}
async onDrop(ev) {
ev.preventDefault();
const targetIndex = this.state.dragOverIndex !== null
@@ -236,12 +276,87 @@ export class FpSimpleRecipeEditor extends Component {
this.state.dragPreviewIcon = "fa-cog";
}
// ------------------------------------------------------------- edit panel
/**
* Toggle the inline edit panel for a step. Closing without explicit
* Save discards changes — operator-style "I clicked the wrong row"
* shouldn't write garbage to the recipe.
*/
onToggleEdit(stepId) {
if (this.state.editingStepId === stepId) {
this.state.editingStepId = null;
this.state.editName = "";
this.state.editInstructions = "";
return;
}
const step = this.state.steps.find((s) => s.id === stepId);
if (!step) return;
this.state.editingStepId = stepId;
this.state.editName = step.name || "";
this.state.editInstructions = this._htmlToText(step.description || "");
}
async onSaveStep() {
const stepId = this.state.editingStepId;
if (!stepId) return;
const vals = {
name: this.state.editName || _t("Untitled Step"),
description: this._textToHtml(this.state.editInstructions),
};
await rpc("/fp/simple_recipe/step/write", {
node_id: stepId,
vals: vals,
});
this.state.editingStepId = null;
this.state.editName = "";
this.state.editInstructions = "";
await this.loadAll();
this.notification.add(_t("Step updated"), { type: "success" });
}
onCancelEdit() {
this.state.editingStepId = null;
this.state.editName = "";
this.state.editInstructions = "";
}
/**
* Render stored HTML as plain text for the textarea. Strips tags,
* collapses block elements to newlines. Good enough for the simple
* editor — the tree editor handles full rich text.
*/
_htmlToText(html) {
if (!html) return "";
const tmp = document.createElement("div");
tmp.innerHTML = html;
// Replace block elements + <br> with newlines before reading text.
tmp.querySelectorAll("br").forEach((br) => br.replaceWith("\n"));
tmp.querySelectorAll("p, div, li").forEach((el) => {
el.append("\n");
});
return (tmp.textContent || "").replace(/\n{3,}/g, "\n\n").trim();
}
/** Wrap user text into safe HTML so the Html field stores cleanly. */
_textToHtml(text) {
if (!text) return "";
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return escaped
.split(/\n{2,}/)
.map((p) => `<p>${p.replace(/\n/g, "<br/>")}</p>`)
.join("");
}
// --------------------------------------------------------------- helpers
async _confirm(message) {
return await new Promise((resolve) => {
this.dialog.add(
"web.ConfirmationDialog",
ConfirmationDialog,
{
body: message,
confirm: () => resolve(true),

View File

@@ -152,6 +152,12 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
background: $fp-se-drop;
border-color: $fp-se-accent;
}
&.o_fp_step_row_editing {
border-color: $fp-se-accent;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
}
.o_fp_drag_handle {
color: $fp-se-muted;
@@ -163,6 +169,10 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
min-width: 1.5rem;
}
.o_fp_step_name { flex: 1; }
.o_fp_step_has_instructions {
color: $fp-se-accent;
font-size: .85rem;
}
.o_fp_station_badge {
font-size: .75rem;
color: $fp-se-muted;
@@ -170,19 +180,64 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
padding: .125rem .5rem;
border-radius: 999px;
}
.o_fp_step_edit,
.o_fp_step_remove {
background: none;
border: none;
color: $fp-se-muted;
font-size: 1.25rem;
cursor: pointer;
opacity: 0;
transition: opacity .1s;
padding: 0 .25rem;
}
&:hover .o_fp_step_remove {
.o_fp_step_edit { font-size: .9rem; }
.o_fp_step_remove { font-size: 1.25rem; }
&:hover .o_fp_step_edit,
&:hover .o_fp_step_remove,
&.o_fp_step_row_editing .o_fp_step_edit {
opacity: 1;
}
.o_fp_step_edit:hover {
color: $fp-se-accent;
}
}
.o_fp_step_edit_panel {
background: $fp-se-card;
border: 1px solid $fp-se-accent;
border-top: none;
border-radius: 0 0 4px 4px;
padding: .75rem;
margin-bottom: .25rem;
.o_fp_edit_field {
margin-bottom: .75rem;
label {
display: block;
font-weight: 500;
font-size: .85rem;
margin-bottom: .25rem;
color: $fp-se-accent;
}
textarea {
font-family: inherit;
resize: vertical;
min-height: 5rem;
}
}
.o_fp_edit_hint {
margin: .25rem 0 0 0;
font-size: .75rem;
color: $fp-se-muted;
}
.o_fp_edit_actions {
display: flex;
gap: .5rem;
justify-content: flex-end;
}
}
.o_fp_step_dropzone {

View File

@@ -34,10 +34,11 @@
<div class="o_fp_simple_editor_body" t-if="!state.loading">
<div class="o_fp_selected_panel"
t-on-dragover="(ev) => this.onPanelDragOver(ev)"
t-on-dragleave="(ev) => this.onDragLeave(ev)"
t-on-dragend="() => this.onDragEnd()"
t-on-drop="(ev) => this.onDrop(ev)">
<h3>Selected (drag to reorder)</h3>
<h3>Selected (drag to reorder, click pencil to edit)</h3>
<div class="o_fp_steps_list">
<!-- Top drop indicator (insertion at index 0). Visible
@@ -53,6 +54,7 @@
<t t-foreach="state.steps" t-as="step" t-key="step.id">
<div class="o_fp_step_row"
t-att-class="state.editingStepId === step.id ? 'o_fp_step_row_editing' : ''"
draggable="true"
t-on-dragstart="(ev) => this.onSelectedDragStart(step.id, ev)"
t-on-dragover="(ev) => this.onRowDragOver(step_index, ev)">
@@ -60,16 +62,58 @@
<span class="o_fp_step_position"><t t-esc="step_index + 1"/>.</span>
<i t-att-class="'fa ' + (step.icon || 'fa-cog')"/>
<span class="o_fp_step_name" t-esc="step.name"/>
<span class="o_fp_step_has_instructions"
t-if="step.description"
title="Has operator instructions">
<i class="fa fa-file-text-o"/>
</span>
<span class="o_fp_station_badge"
t-if="step.tank_ids and step.tank_ids.length">
<t t-esc="step.tank_ids.length"/> stations
</span>
<button class="o_fp_step_edit"
title="Edit name &amp; instructions"
t-on-click="() => this.onToggleEdit(step.id)">
<i class="fa fa-pencil"/>
</button>
<button class="o_fp_step_remove"
title="Remove step"
t-on-click="() => this.onRemoveStep(step.id)">
×
</button>
</div>
<!-- Inline edit panel (shown when this step is selected for editing). -->
<div class="o_fp_step_edit_panel"
t-if="state.editingStepId === step.id">
<div class="o_fp_edit_field">
<label>Step name</label>
<input type="text" class="form-control"
t-model="state.editName"
placeholder="e.g. Acid Etch"/>
</div>
<div class="o_fp_edit_field">
<label>Default instructions for operator</label>
<textarea class="form-control"
rows="5"
t-model="state.editInstructions"
placeholder="What the operator/employee sees on the shop floor when running this step. Plain text — line breaks are preserved."/>
<p class="o_fp_edit_hint">
Shown to operators when running this step at the tank. Use line breaks for separate points.
</p>
</div>
<div class="o_fp_edit_actions">
<button class="btn btn-primary btn-sm"
t-on-click="() => this.onSaveStep()">
Save
</button>
<button class="btn btn-secondary btn-sm"
t-on-click="() => this.onCancelEdit()">
Cancel
</button>
</div>
</div>
<!-- Indicator AFTER each row (insertion at index = step_index + 1) -->
<div class="o_fp_drop_indicator"
t-att-class="state.dragOverIndex === (step_index + 1) ? 'o_fp_drop_indicator_active' : ''">

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.18.2.0',
'version': '19.0.18.3.2',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """

View File

@@ -13,6 +13,30 @@ from odoo import fields, models
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
def fp_customer_description(self):
"""Strip the "[code] product_name" prefix from line.name.
Mirror of sale.order.line.fp_customer_description so the shared
customer_line_description QWeb macro renders cleanly on invoice
PDFs too.
"""
self.ensure_one()
name = (self.name or '').strip()
if not self.product_id or not name:
return name
code = self.product_id.default_code or ''
pname = self.product_id.name or ''
prefixes = []
if code and pname:
prefixes.append(f'[{code}] {pname}')
if pname:
prefixes.append(pname)
for prefix in prefixes:
if name.startswith(prefix):
tail = name[len(prefix):]
return tail.lstrip(' \t\r\n-—–:').strip()
return name
x_fc_part_catalog_id = fields.Many2one(
'fp.part.catalog',
string='Part',

View File

@@ -25,9 +25,26 @@ class FpCoatingThickness(models.Model):
ondelete='cascade',
)
value = fields.Float(
string='Nominal',
digits=(10, 4),
required=True,
help='Target thickness value (magnitude only; UoM in the next field).',
help='Target / nominal thickness value (the number printed on the cert). '
'Magnitude only — UoM lives in the next field.',
)
# Hitting an exact thickness on plated parts is impossible — the spec
# is always "X mils ± tolerance" or a min/max range. These fields
# capture the acceptance band so QC can mark a reading pass/fail
# against real customer specs (e.g. AMS-2404 Class 4 = 0.001"0.0015").
# Both optional: leave blank for legacy single-value entries.
value_min = fields.Float(
string='Min',
digits=(10, 4),
help='Lower acceptance bound. Readings below this fail QC.',
)
value_max = fields.Float(
string='Max',
digits=(10, 4),
help='Upper acceptance bound. Readings above this fail QC.',
)
uom = fields.Selection(
[('mils', 'mils (0.001 in)'),
@@ -44,7 +61,7 @@ class FpCoatingThickness(models.Model):
store=True,
)
@api.depends('value', 'uom')
@api.depends('value', 'value_min', 'value_max', 'uom')
def _compute_display_name(self):
uom_labels = dict(self._fields['uom'].selection)
for rec in self:
@@ -52,7 +69,22 @@ class FpCoatingThickness(models.Model):
# Strip the bracketed clarification for a tighter dropdown row.
if ' (' in label:
label = label.split(' (')[0]
if rec.value:
# Range overrides single value when both bounds are set —
# operators see the real spec, not a phantom-precise nominal.
if rec.value_min and rec.value_max:
rec.display_name = (
f'{rec.value_min:g}{rec.value_max:g} {label}'.strip()
)
elif rec.value:
rec.display_name = f'{rec.value:g} {label}'.strip()
else:
rec.display_name = label
@api.constrains('value_min', 'value_max')
def _check_range(self):
for rec in self:
if rec.value_min and rec.value_max and rec.value_min > rec.value_max:
from odoo.exceptions import ValidationError
raise ValidationError(_(
'Thickness Min (%(mn)s) cannot exceed Max (%(mx)s).'
) % {'mn': rec.value_min, 'mx': rec.value_max})

View File

@@ -10,6 +10,36 @@ from odoo.exceptions import ValidationError
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
def fp_customer_description(self):
"""Return line.name with the leading "[code] product_name" stripped.
Odoo's _compute_name re-prepends the product code + name on save,
polluting customer-facing PDFs with internal-product noise like
"[FP-SERVICE] Plating Service". This helper peels that prefix
off so the QWeb macros print only what the estimator actually
typed for the customer to see. Same logic mirrored on
account.move.line for invoice rendering.
"""
self.ensure_one()
name = (self.name or '').strip()
if not self.product_id or not name:
return name
code = self.product_id.default_code or ''
pname = self.product_id.name or ''
# Try the bracketed form first ("[CODE] Name"), then bare name.
# Whichever matches gets stripped along with any trailing
# newline / dash / em-dash separator.
prefixes = []
if code and pname:
prefixes.append(f'[{code}] {pname}')
if pname:
prefixes.append(pname)
for prefix in prefixes:
if name.startswith(prefix):
tail = name[len(prefix):]
return tail.lstrip(' \t\r\n-—–:').strip()
return name
x_fc_part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part',
)

View File

@@ -77,7 +77,9 @@
<field name="thickness_option_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="value"/>
<field name="value" string="Nominal"/>
<field name="value_min" string="Min"/>
<field name="value_max" string="Max"/>
<field name="uom"/>
<field name="display_name" string="Display" readonly="1"/>
<field name="active" widget="boolean_toggle"/>

View File

@@ -100,11 +100,39 @@
</xpath>
<xpath expr="//notebook" position="inside">
<page string="Plating" name="plating_tab">
<group>
<group string="Part &amp; Coating">
<field name="x_fc_configurator_id" readonly="1"/>
<!-- Multi-part summary: read-only list of every order line
showing part / coating / process. The Order Lines tab
is the editable surface; this is the at-a-glance view
so you can confirm an order has the right parts/coatings
without scrolling pricing columns. The pre-Sub-12 SO-
header singletons (x_fc_part_catalog_id /
x_fc_coating_config_id) only ever populated when the
order was built via the quote configurator — they're
silent on direct orders, which is why they appeared
empty after confirm. They still exist on the model
(used by configurator/portal) but are no longer the
primary display. -->
<separator string="Parts on this order"/>
<field name="order_line" nolabel="1"
context="{'tree_view_ref': 'fusion_plating_configurator.view_sale_order_line_plating_summary'}"
readonly="1">
<list create="false" delete="false" edit="false">
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="x_fc_thickness_id" optional="show"/>
<field name="x_fc_process_variant_id" optional="show"
string="Process"/>
<field name="product_uom_qty" string="Qty"/>
<field name="x_fc_part_deadline" optional="show"
string="Part Deadline"/>
<field name="x_fc_rush_order" optional="hide"/>
<field name="x_fc_job_number" optional="show"
string="Job #"/>
</list>
</field>
<group>
<group string="Configurator (legacy)" invisible="not x_fc_configurator_id">
<field name="x_fc_configurator_id" readonly="1"/>
<field name="x_fc_process_summary" readonly="1"/>
</group>
<group string="RFQ / PO">

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError
@@ -220,23 +222,39 @@ class FpDirectOrderWizard(models.Model):
self._apply_strategy_payment_term()
return
# Legacy partner-field defaults (pre-Sub-5).
if 'x_fc_default_invoice_strategy' in self.partner_id._fields:
self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False
self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0
# Partner-level plating defaults — primary cascade. Customers
# migrated to the new partner fields skip the legacy lookup below.
partner = self.partner_id
if partner.x_fc_default_invoice_strategy:
self.invoice_strategy = partner.x_fc_default_invoice_strategy
if partner.x_fc_default_deposit_percent:
self.deposit_percent = partner.x_fc_default_deposit_percent
if partner.x_fc_default_delivery_method:
self.delivery_method = partner.x_fc_default_delivery_method
# Deadline auto-fill — anchored to planned_start_date with today
# as fallback. Honours explicit deadlines the user already typed.
anchor = self.planned_start_date or fields.Date.context_today(self)
if (partner.x_fc_default_internal_deadline_days
and not self.internal_deadline):
self.internal_deadline = (
anchor + timedelta(days=partner.x_fc_default_internal_deadline_days)
)
if (partner.x_fc_default_customer_deadline_days
and not self.customer_deadline):
self.customer_deadline = (
anchor + timedelta(days=partner.x_fc_default_customer_deadline_days)
)
# Addresses.
addrs = self.partner_id.address_get(['invoice', 'delivery'])
self.partner_invoice_id = addrs.get('invoice') or self.partner_id.id
self.partner_shipping_id = addrs.get('delivery') or self.partner_id.id
addrs = partner.address_get(['invoice', 'delivery'])
self.partner_invoice_id = addrs.get('invoice') or partner.id
self.partner_shipping_id = addrs.get('delivery') or partner.id
# Per-customer invoice strategy default (fp.invoice.strategy.default).
# Pull strategy + deposit even when payment_term_id is empty — the
# previous condition `if isd and isd.payment_term_id` silently
# skipped the strategy fill for net-terms customers without
# explicit terms configured.
# Legacy fallback: fp.invoice.strategy.default (kept for sites
# mid-migration). Only fills gaps the partner fields didn't cover.
isd = self.env['fp.invoice.strategy.default'].search(
[('partner_id', '=', self.partner_id.id)], limit=1,
[('partner_id', '=', partner.id)], limit=1,
)
term = False
if isd:
@@ -245,8 +263,8 @@ class FpDirectOrderWizard(models.Model):
if not self.deposit_percent:
self.deposit_percent = isd.default_deposit_percent or 0.0
term = isd.payment_term_id
if not term and self.partner_id.property_payment_term_id:
term = self.partner_id.property_payment_term_id
if not term and partner.property_payment_term_id:
term = partner.property_payment_term_id
self.payment_term_id = term or False
# Re-apply strategy → terms mapping after partner switch.
@@ -271,6 +289,29 @@ class FpDirectOrderWizard(models.Model):
"""Map the strategy onto sensible payment terms."""
self._apply_strategy_payment_term()
@api.onchange('planned_start_date')
def _onchange_planned_start_date(self):
"""Recompute deadlines from partner offsets when start moves.
Runs only if the partner has offsets configured AND deadlines
are still blank — typing a manual deadline locks it.
"""
if not self.partner_id or not self.planned_start_date:
return
partner = self.partner_id
if (partner.x_fc_default_internal_deadline_days
and not self.internal_deadline):
self.internal_deadline = (
self.planned_start_date
+ timedelta(days=partner.x_fc_default_internal_deadline_days)
)
if (partner.x_fc_default_customer_deadline_days
and not self.customer_deadline):
self.customer_deadline = (
self.planned_start_date
+ timedelta(days=partner.x_fc_default_customer_deadline_days)
)
def _apply_strategy_payment_term(self):
"""Mapping rule:
- cod_prepay → Immediate Payment
@@ -435,6 +476,12 @@ class FpDirectOrderWizard(models.Model):
[('default_code', '=', 'FP-SERVICE')], limit=1,
)
if not product:
# Seed the product with the company's default sale tax so the
# customer's fiscal position has something to RE-MAP. Without
# this, lines come out tax-free regardless of how the customer's
# fiscal position is configured (fiscal positions only re-map
# existing taxes; they don't manufacture them).
default_sale_tax = self.env.company.account_sale_tax_id
product = self.env['product.product'].create({
'name': 'Plating Service',
'default_code': 'FP-SERVICE',
@@ -442,7 +489,14 @@ class FpDirectOrderWizard(models.Model):
'list_price': 0,
'sale_ok': True,
'purchase_ok': False,
'taxes_id': [(6, 0, default_sale_tax.ids)] if default_sale_tax else False,
})
elif not product.taxes_id and self.env.company.account_sale_tax_id:
# Self-heal: pre-existing FP-SERVICE without taxes (created in
# an earlier version) silently produced tax-free lines. Top up
# with the company default sale tax so customer fiscal positions
# can re-map correctly.
product.taxes_id = [(6, 0, self.env.company.account_sale_tax_id.ids)]
# 3. Build SO header
so_vals = {

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Invoicing',
'version': '19.0.3.2.0',
'version': '19.0.3.3.0',
'category': 'Manufacturing/Plating',
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
'description': """

View File

@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Backfill plating defaults from fp.invoice.strategy.default → res.partner.
v3.3 merges the per-customer invoice strategy onto the partner record
itself so the new "Plating Defaults" tab is the single source of truth.
Only plain columns are migrated here (invoice_strategy + deposit %).
The legacy `fp.invoice.strategy.default` model is left in place; the
new sale.order onchange falls back to it for any partner whose record
hasn't been migrated, so downstream code keeps working mid-rollout.
property_payment_term_id is intentionally skipped — it lives in
ir_property rather than as a plain column, and the legacy onchange
fallback already reads payment_term from the strategy default record
when the partner doesn't have one set directly.
"""
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return
cr.execute("""
UPDATE res_partner p
SET x_fc_default_invoice_strategy = COALESCE(
p.x_fc_default_invoice_strategy, isd.default_strategy),
x_fc_default_deposit_percent = COALESCE(
NULLIF(p.x_fc_default_deposit_percent, 0),
isd.default_deposit_percent)
FROM fp_invoice_strategy_default isd
WHERE isd.partner_id = p.id
""")
_logger.info(
'fusion_plating_invoicing migration 19.0.3.3.0: backfilled %d '
'partner records from fp.invoice.strategy.default',
cr.rowcount,
)

View File

@@ -9,6 +9,7 @@ from odoo import fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
# ===== Account hold (existing) ============================================
x_fc_account_hold = fields.Boolean(
string='Account Hold', tracking=True,
help='When active, blocks SO confirmation, invoicing, and shipping.',
@@ -20,3 +21,45 @@ class ResPartner(models.Model):
x_fc_account_hold_by_id = fields.Many2one(
'res.users', string='Hold Placed By',
)
# ===== Plating Defaults (cascade onto every new SO for this customer) =====
# The estimator sets these once on the customer record; they pre-fill
# invoice strategy, delivery method, and deadlines on every new SO so
# repeat customers don't need re-typing the same values each order.
# Tax type lives on `property_account_position_id` (Odoo native fiscal
# position) and payment terms on `property_payment_term_id` — both are
# surfaced on the same Plating Defaults tab in the partner form.
x_fc_default_invoice_strategy = fields.Selection(
[('deposit', 'Deposit'),
('progress', 'Progress Billing'),
('net_terms', 'Net Terms'),
('cod_prepay', 'COD / Prepay')],
string='Default Invoice Strategy',
help='Pre-fills the SO invoice strategy when this customer is selected. '
'The estimator can still override per order.',
)
x_fc_default_deposit_percent = fields.Float(
string='Default Deposit %',
help='Used when invoice strategy is "Deposit". e.g. 50.0 for 50%.',
)
x_fc_default_delivery_method = fields.Selection(
[('local_delivery', 'Local Delivery'),
('shipping_partner', 'Shipping Partner'),
('customer_pickup', 'Customer Pickup')],
string='Default Delivery Method',
help='Pre-fills the SO delivery method when this customer is selected.',
)
# Lead-time defaults are expressed as offsets FROM the SO's planned-start
# date so they track real production schedules, not just "today + N".
# If planned_start is unset on the SO, the cascade falls back to today.
x_fc_default_internal_deadline_days = fields.Integer(
string='Internal Deadline (+ days from start)',
help='Pre-fills SO internal deadline as planned_start_date + this '
'many days. e.g. 5 means "ship five days after we start".',
)
x_fc_default_customer_deadline_days = fields.Integer(
string='Customer Deadline (+ days from start)',
help='Pre-fills the customer-facing commitment date as '
'planned_start_date + this many days.',
)

View File

@@ -4,6 +4,7 @@
# Part of the Fusion Plating product family.
import logging
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
@@ -16,16 +17,70 @@ class SaleOrder(models.Model):
@api.onchange('partner_id')
def _onchange_partner_id_invoice_strategy(self):
"""Auto-fill invoice strategy from customer defaults."""
if self.partner_id:
default = self.env['fp.invoice.strategy.default'].search(
[('partner_id', '=', self.partner_id.id)], limit=1,
)
if default:
self.x_fc_invoice_strategy = default.default_strategy
self.x_fc_deposit_percent = default.default_deposit_percent
if default.payment_term_id:
self.payment_term_id = default.payment_term_id
"""Auto-fill plating defaults from customer profile.
Cascade order: partner-level defaults first (the new fast-order
path), then fall back to the legacy fp.invoice.strategy.default
records for customers migrated before that model was retired.
Native Odoo cascades (payment terms, fiscal position) handle
themselves via property_* fields and don't need code here.
"""
if not self.partner_id:
return
partner = self.partner_id
if partner.x_fc_default_invoice_strategy:
self.x_fc_invoice_strategy = partner.x_fc_default_invoice_strategy
if partner.x_fc_default_deposit_percent:
self.x_fc_deposit_percent = partner.x_fc_default_deposit_percent
if partner.x_fc_default_delivery_method:
self.x_fc_delivery_method = partner.x_fc_default_delivery_method
self._fp_recompute_default_deadlines()
# Legacy fallback: invoice strategy default model. Only fills
# gaps left by the partner fields above so a partial migration
# doesn't clobber explicit partner-level values.
legacy = self.env['fp.invoice.strategy.default'].search(
[('partner_id', '=', partner.id)], limit=1,
)
if legacy:
if not self.x_fc_invoice_strategy:
self.x_fc_invoice_strategy = legacy.default_strategy
if not self.x_fc_deposit_percent:
self.x_fc_deposit_percent = legacy.default_deposit_percent
if legacy.payment_term_id and not self.payment_term_id:
self.payment_term_id = legacy.payment_term_id
@api.onchange('x_fc_planned_start_date')
def _onchange_planned_start_date_deadlines(self):
"""Recompute deadlines when planned start changes — without it
the partner offsets would only fire on partner_id change."""
self._fp_recompute_default_deadlines()
def _fp_recompute_default_deadlines(self):
"""Apply partner deadline offsets relative to planned_start_date.
Falls back to today when planned_start is unset so the estimator
gets a value immediately. Never overwrites a deadline already
set by the user (we honour explicit input over auto-fill).
"""
for order in self:
partner = order.partner_id
if not partner:
continue
anchor = order.x_fc_planned_start_date or fields.Date.context_today(order)
if (partner.x_fc_default_internal_deadline_days
and not order.x_fc_internal_deadline):
order.x_fc_internal_deadline = (
anchor + timedelta(days=partner.x_fc_default_internal_deadline_days)
)
if (partner.x_fc_default_customer_deadline_days
and not order.commitment_date):
order.commitment_date = (
anchor + timedelta(days=partner.x_fc_default_customer_deadline_days)
)
def action_confirm(self):
"""Override to check account hold + customer PO# and trigger

View File

@@ -23,7 +23,36 @@
</small>
</div>
</xpath>
<!-- Single "Plating Defaults" tab — invoice strategy, delivery,
deadlines, tax type, payment terms. Set once here, cascades
onto every new SO for this customer. -->
<xpath expr="//notebook" position="inside">
<page string="Plating Defaults" name="fp_plating_defaults_tab"
invisible="is_company == False and parent_id"
groups="fusion_plating_invoicing.group_fp_accounting">
<p class="text-muted">
Set defaults once per customer to speed up order entry.
These cascade onto every new sale order; the estimator
can override per order.
</p>
<group>
<group string="Invoicing">
<field name="x_fc_default_invoice_strategy"/>
<field name="x_fc_default_deposit_percent"
invisible="x_fc_default_invoice_strategy != 'deposit'"/>
<field name="property_payment_term_id"/>
<field name="property_account_position_id"
string="Tax Type (Fiscal Position)"/>
</group>
<group string="Fulfilment">
<field name="x_fc_default_delivery_method"/>
<field name="x_fc_default_internal_deadline_days"/>
<field name="x_fc_default_customer_deadline_days"/>
</group>
</group>
</page>
<page string="Account Hold" name="account_hold_tab"
groups="fusion_plating_invoicing.group_fp_accounting">
<group>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.8.0',
'version': '19.0.8.11.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -202,9 +202,9 @@ class FpJob(models.Model):
job.racking_inspection_state = ri.state if ri else False
def action_view_racking_inspection(self):
"""Open the racking inspection. Auto-create if missing (e.g. job
was created before Sub 8 shipped, or auto-create silently failed
at action_confirm time)."""
"""Open the racking inspection. Auto-create if missing, or seed
lines from the SO if it exists but was created before line auto-
seeding shipped (the helper handles both cases idempotently)."""
self.ensure_one()
if 'fp.racking.inspection' not in self.env:
from odoo.exceptions import UserError
@@ -212,9 +212,12 @@ class FpJob(models.Model):
'Sub 8 racking inspection module not installed. '
'Install fusion_plating_receiving to enable.'
))
if not self.racking_inspection_id:
self._fp_create_racking_inspection()
self.invalidate_recordset(['racking_inspection_ids'])
# Always call the helper — it short-circuits for already-populated
# draft inspections and creates fresh ones when missing. This is
# also the entry point that backfills lines on inspections that
# pre-date the line-seeding feature.
self._fp_create_racking_inspection()
self.invalidate_recordset(['racking_inspection_ids'])
ri = self.racking_inspection_id or self.racking_inspection_ids[:1]
if not ri:
from odoo.exceptions import UserError
@@ -239,11 +242,39 @@ class FpJob(models.Model):
'context': {'default_job_id': self.id},
}
def action_finish_current_step(self):
"""Steelhead-style header button: finish whatever's currently
in_progress and auto-start the next pending/ready step. If
nothing is running yet, start the lowest-sequence pending step
instead — operator's first click on a fresh job just begins
the line.
"""
self.ensure_one()
running = self.step_ids.filtered(lambda s: s.state == 'in_progress')[:1]
if running:
return running.action_finish_and_advance()
# No running step — kick off the first pending/ready one.
first = self.step_ids.filtered(
lambda s: s.state in ('pending', 'ready', 'paused')
).sorted('sequence')[:1]
if not first:
raise UserError(_(
'No runnable step found on this job — either every step '
'is done or the job is still in draft.'
))
first.with_context(fp_skip_predecessor_check=True).button_start()
self.message_post(body=_(
'Started first step "%s".'
) % first.name)
return True
def action_open_move_wizard(self):
"""Header button — opens the Move wizard pre-filled with the
currently in-progress (or most recently in-progress) step as the
from-step. Lets the manager move the job forward without first
clicking into a specific step row.
"""Original Move wizard — kept available for cross-station moves
and rework / scrap transfers. The simple "finish current → start
next" flow is now action_finish_current_step (header button).
Opens the wizard pre-filled with the currently in-progress (or
most recently in-progress) step as the from-step.
"""
self.ensure_one()
active_step = self.step_ids.filtered(
@@ -871,6 +902,9 @@ class FpJob(models.Model):
production_id too so legacy reports keep working.
Idempotent — if an inspection already exists for this job, skip.
Either way the inspection's lines are seeded from the SO's
plating order lines so the racker walks into a pre-populated
checklist instead of an empty form.
"""
self.ensure_one()
if 'fp.racking.inspection' not in self.env:
@@ -883,17 +917,62 @@ class FpJob(models.Model):
('x_fc_job_id', '=', self.id),
], limit=1)
if existing:
# Self-heal: pre-existing inspections from before line seeding
# was added show up empty. Top them up now if still empty +
# the inspection isn't already finalised (don't rewrite history).
if not existing.line_ids and existing.state == 'draft':
self._fp_seed_racking_lines(existing)
return
# Phase 6 (Sub 11) — production_id retired; bind by x_fc_job_id only.
vals = {'x_fc_job_id': self.id}
try:
Inspection.create(vals)
insp = Inspection.create(vals)
self._fp_seed_racking_lines(insp)
except Exception as e:
_logger.warning(
"Job %s: failed to auto-create racking inspection: %s",
self.name, e,
)
def _fp_seed_racking_lines(self, inspection):
"""Populate the inspection with one line per SO plating order line.
Walks sale_order_line_ids (the M2M of SO lines tied to this job),
falling back to the linked SO's order_line. Each line carries the
part_catalog and the quoted qty as the expected count — the
racker confirms or amends on the floor.
"""
self.ensure_one()
if not inspection or inspection.line_ids:
return
Line = self.env['fp.racking.inspection.line'].sudo()
# Source preference: explicit M2M of plating lines bound to this
# job (fast-order multi-part jobs), falling back to the SO header.
so_lines = self.sale_order_line_ids
if not so_lines and self.sale_order_id:
so_lines = self.sale_order_id.order_line
plating_lines = so_lines.filtered(
lambda l: l.x_fc_part_catalog_id and not l.display_type
)
if not plating_lines:
return
seq = 10
for sol in plating_lines:
try:
Line.create({
'inspection_id': inspection.id,
'sequence': seq,
'part_catalog_id': sol.x_fc_part_catalog_id.id,
'qty_expected': int(sol.product_uom_qty or 0),
'condition': 'ok',
})
except Exception as e:
_logger.warning(
"Job %s: failed to seed racking line for SO line %s: %s",
self.name, sol.id, e,
)
seq += 10
def _fp_create_portal_job(self):
"""Create the fusion.plating.portal.job mirror record."""
self.ensure_one()

View File

@@ -326,6 +326,98 @@ class FpJobStep(models.Model):
)) % (step.name, old, new, new - old, self.env.user.name))
return True
def action_finish_and_advance(self):
"""Steelhead-style "Finish & Next" — finish this step then auto-
start the next pending/ready step in sequence. Single click
replaces the prior Finish-then-Move-wizard dance.
If the step has authored step_input prompts AND none have been
captured yet, we route through the simplified Record Inputs
wizard first; saving the wizard re-enters here with the
`fp_after_inputs=True` context flag so we don't loop.
"""
self.ensure_one()
if self.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — start it before clicking Finish."
) % (self.name, self.state))
# Prompt-first behaviour: show the Record Inputs dialog when the
# recipe step has authored prompts and nothing has been captured
# in this run. Bypass when context flag is set (i.e. we're being
# called BACK from the wizard's commit), or when the operator
# already saved values via the Record Inputs button earlier.
if (not self.env.context.get('fp_after_inputs')
and self._fp_has_uncaptured_step_inputs()):
return self._fp_open_input_wizard(advance_after=True)
self.button_finish()
next_step = self._fp_next_runnable_step()
if next_step:
next_step.with_context(
fp_skip_predecessor_check=True,
).button_start()
self.job_id.message_post(body=_(
'Step "%(prev)s" finished — auto-started next step "%(next)s".'
) % {'prev': self.name, 'next': next_step.name})
return True
def _fp_next_runnable_step(self):
"""The lowest-sequence step on this job that isn't terminal yet
and isn't this one. Used by action_finish_and_advance."""
self.ensure_one()
candidates = self.job_id.step_ids.filtered(
lambda s: s.id != self.id
and s.state in ('pending', 'ready', 'paused')
).sorted('sequence')
return candidates[:1] or self.env['fp.job.step']
def _fp_has_uncaptured_step_inputs(self):
"""True when the recipe step defines step_input prompts AND
the user hasn't already saved values for this step's current
run via the Record Inputs wizard.
"""
self.ensure_one()
node = self.recipe_node_id
if not node:
return False
prompts = node.input_ids
if 'kind' in prompts._fields:
prompts = prompts.filtered(lambda i: i.kind == 'step_input')
if not prompts:
return False
# Has the operator already recorded values during this run?
# Heuristic: any in-place fp.job.step.move (transfer_type='step')
# for this step since date_started.
Move = self.env['fp.job.step.move']
already = Move.search_count([
('from_step_id', '=', self.id),
('transfer_type', '=', 'step'),
('move_datetime', '>=', self.date_started or fields.Datetime.now()),
])
return already == 0
def _fp_open_input_wizard(self, advance_after=False):
"""Open the simplified Record Inputs dialog. When advance_after
is True, the wizard's Save button finishes the step and starts
the next one as a single atomic flow."""
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id(
'fusion_plating_jobs.action_fp_job_step_input_wizard'
)
action['context'] = {
**dict(self.env.context),
'default_step_id': self.id,
'active_id': self.id,
'fp_advance_after_save': advance_after,
}
return action
# NB: action_open_input_wizard is defined further down (line ~829)
# — that one stays as the per-row "Record" button entry-point.
# _fp_open_input_wizard above adds the advance_after pathway used
# only by action_finish_and_advance.
def button_finish(self):
"""Override to:
1) Auto-spawn a bake.window when a wet plating step finishes

View File

@@ -18,11 +18,16 @@
<field name="name">FP Traveller — A4 landscape narrow margins</field>
<field name="format">A4</field>
<field name="orientation">Landscape</field>
<field name="margin_top">10</field>
<!-- margin_top + header_spacing both reserve room above the body
so the H1 / Item Information table doesn't ride into the
external_layout's company logo band. The screenshot showed
"Work Order / Bon de Travail" overlapping the ENTECH logo
with the prior 10 / 5 values; 28 / 22 buys ~1cm clear gap. -->
<field name="margin_top">28</field>
<field name="margin_bottom">10</field>
<field name="margin_left">8</field>
<field name="margin_right">8</field>
<field name="header_spacing">5</field>
<field name="header_spacing">22</field>
<field name="dpi">90</field>
</record>

View File

@@ -21,11 +21,17 @@
<field name="name">FP Work Order Detail — A4 portrait</field>
<field name="format">A4</field>
<field name="orientation">Portrait</field>
<field name="margin_top">15</field>
<!-- margin_top + header_spacing both reserve room above the body
content. The external_layout puts the company logo + address
in that band; without enough space the header overlaps the
body's first line (the H1 on page 1, the Certified By table
on page 2). 35 / 28 puts a clean ~1cm clear gap below the
logo block. -->
<field name="margin_top">35</field>
<field name="margin_bottom">15</field>
<field name="margin_left">12</field>
<field name="margin_right">12</field>
<field name="header_spacing">8</field>
<field name="header_spacing">28</field>
<field name="dpi">90</field>
</record>
@@ -46,14 +52,42 @@
<t t-foreach="docs" t-as="job">
<t t-call="web.external_layout">
<t t-set="company" t-value="job.company_id"/>
<t t-set="moves" t-value="job.move_ids.sorted('move_datetime')"/>
<t t-set="so" t-value="job.sale_order_id"/>
<!-- All datetimes in Postgres are naive UTC. QWeb's
eval scope exposes neither pytz nor format_datetime,
so timestamp formatting happens via job.fp_format_local()
on the record itself — record methods are always
available in templates. The helper resolves user.tz
→ company.x_fc_default_tz → UTC. -->
<!-- First SO line linked to this job — source of truth
for the customer-facing description, serial(s),
and part metadata. -->
<t t-set="primary_line" t-value="job.sale_order_line_ids[:1]"/>
<t t-set="po_number"
t-value="(so and (so.client_order_ref or (
'x_fc_po_number' in so._fields and so.x_fc_po_number) or ''))
or ''"/>
<t t-set="customer_desc"
t-value="primary_line and primary_line.fp_customer_description() or ''"/>
<t t-set="serial_names"
t-value="primary_line and 'x_fc_serial_ids' in primary_line._fields
and ', '.join(primary_line.x_fc_serial_ids.mapped('name'))
or ''"/>
<!-- Walk EVERY step in sequence, not just moves. The
old report only rendered moves so steps without
recorded measurements (just Finish & Next) never
appeared on the cert. -->
<t t-set="all_steps" t-value="job.step_ids.filtered(
lambda s: s.state not in ('cancelled',)
).sorted('sequence')"/>
<div class="page fp-wo-detail">
<style>
.fp-wo-detail { font-family: Arial, sans-serif; font-size: 9pt; color: #000; }
.fp-wo-detail h1 { text-align: center; font-size: 18pt; margin: 0 0 6px 0; font-weight: bold; color: #1a4d80; }
.fp-wo-detail h3 { font-size: 11pt; margin: 8px 0 2px 0; font-weight: bold; }
.fp-wo-detail .fp-meta { font-size: 8.5pt; color: #444; margin-bottom: 4px; }
.fp-wo-detail h1 { text-align: center; font-size: 18pt; margin: 0 0 14px 0; font-weight: bold; color: #1a4d80; }
.fp-wo-detail h3 { font-size: 11pt; margin: 12px 0 4px 0; font-weight: bold; }
.fp-wo-detail .fp-meta { font-size: 8.5pt; color: #444; margin-bottom: 6px; }
.fp-wo-detail table.bordered,
.fp-wo-detail table.bordered th,
.fp-wo-detail table.bordered td { border: 1px solid #000; border-collapse: collapse; }
@@ -61,15 +95,16 @@
.fp-wo-detail table.bordered th { background: #ededed; padding: 4px 6px; font-size: 8.5pt; text-align: left; }
.fp-wo-detail table.bordered td { padding: 4px 6px; vertical-align: top; font-size: 8.5pt; }
.fp-wo-detail .text-center { text-align: center; }
.fp-wo-detail hr.heavy { border: 0; border-top: 2px solid #000; margin: 8px 0; }
.fp-wo-detail .fp-spec { font-size: 10pt; font-weight: bold; margin: 8px 0 4px 0; }
.fp-wo-detail .fp-step-block { page-break-inside: avoid; margin-bottom: 6px; }
.fp-wo-detail hr.heavy { border: 0; border-top: 2px solid #000; margin: 12px 0; }
.fp-wo-detail .fp-spec { font-size: 10pt; font-weight: bold; margin: 10px 0 6px 0; }
.fp-wo-detail .fp-step-block { page-break-inside: avoid; margin-bottom: 14px; }
.fp-wo-detail .fp-prepared { margin-bottom: 14px; }
</style>
<h1>Work Order Detail</h1>
<!-- ===== HEADER — Prepared For + summary table ===== -->
<div style="margin-bottom: 8px;">
<div class="fp-prepared">
<strong>Prepared For:</strong>
<span style="font-size: 11pt;"
t-esc="(job.partner_id and job.partner_id.name) or '—'"/>
@@ -77,35 +112,41 @@
<table class="bordered">
<tr>
<th style="width: 20%;">Part Number</th>
<th style="width: 18%;">Part Number</th>
<th style="width: 30%;">Description</th>
<th style="width: 8%;">Quantity</th>
<th style="width: 10%;">Work Order</th>
<th style="width: 14%;">PO Number</th>
<th style="width: 8%;">Packing List No</th>
<th style="width: 7%;">Quantity</th>
<th style="width: 11%;">Work Order</th>
<th style="width: 12%;">PO Number</th>
<th style="width: 12%;">Serial No</th>
<th style="width: 10%;">Date</th>
</tr>
<tr>
<td>
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
<span t-esc="job.part_catalog_id.part_number or '—'"/>
<t t-if="'revision' in job.part_catalog_id._fields and job.part_catalog_id.revision">
<br/>
<span style="font-size: 7.5pt;">Rev <span t-esc="job.part_catalog_id.revision"/></span>
</t>
</t>
<t t-else="">
<span t-esc="(job.product_id and job.product_id.default_code) or '—'"/>
</t>
</td>
<td style="white-space: pre-wrap;">
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
<span t-esc="job.part_catalog_id.name or job.product_id.name or '—'"/>
</t>
<t t-else="">
<span t-esc="(job.product_id and job.product_id.name) or '—'"/>
</t>
<t t-if="'special_requirements' in job._fields and job.special_requirements">
<br/>
<span style="font-size: 7.5pt;"
t-esc="job.special_requirements"/>
</t>
<td style="vertical-align: top;">
<!-- Customer-facing description. The
pre-line wrapper lives on an
INNER div, not the <td>: keeping
pre-line on the cell rendered
the indentation between <td>
and <t t-if> as literal blank
lines, pushing the description
halfway down the cell. The div
only sees the t-esc'd text, so
pre-line preserves the operator's
intentional \n\n paragraph
breaks but nothing else. -->
<div style="white-space: pre-line;"><t t-if="customer_desc"><span t-esc="customer_desc.strip()"/></t><t t-elif="'part_catalog_id' in job._fields and job.part_catalog_id"><span t-esc="job.part_catalog_id.name or job.product_id.name or '—'"/></t><t t-else=""><span t-esc="(job.product_id and job.product_id.name) or '—'"/></t></div>
</td>
<td class="text-center">
<span t-esc="job.qty"/>
@@ -114,11 +155,15 @@
<span t-esc="job.name"/>
</td>
<td>
<span t-esc="(job.sale_order_id and job.sale_order_id.client_order_ref) or '—'"/>
<span t-esc="po_number or '—'"/>
</td>
<td/>
<td>
<span t-esc="(job.date_finished or job.date_started or job.create_date) and (job.date_finished or job.date_started or job.create_date).strftime('%Y-%m-%d') or ''"/>
<span t-esc="serial_names or '—'"/>
</td>
<td>
<t t-set="_hdr_dt"
t-value="job.date_finished or job.date_started or job.create_date"/>
<span t-esc="job.fp_format_local(_hdr_dt, '%Y-%m-%d')"/>
</td>
</tr>
</table>
@@ -130,15 +175,39 @@
<hr class="heavy"/>
<!-- ===== CHAIN-OF-CUSTODY WALK ===== -->
<t t-foreach="moves" t-as="mv">
<t t-set="dest" t-value="mv.to_step_id"/>
<t t-set="tank_code" t-value="(mv.to_tank_id and mv.to_tank_id.code) or (dest and dest.tank_id and dest.tank_id.code) or ''"/>
<!-- ===== STEPS WALK ===== -->
<t t-foreach="all_steps" t-as="step">
<!-- Aggregate captured input values from any
move that touches this step (incoming or
outgoing — the Record Inputs wizard
creates a self-loop move with from=to=step). -->
<t t-set="step_moves"
t-value="job.move_ids.filtered(
lambda m: m.from_step_id == step or m.to_step_id == step
).sorted('move_datetime')"/>
<t t-set="step_values"
t-value="step_moves.mapped('transition_input_value_ids')"/>
<!-- Pick a representative "Moved By" / Time:
prefer the step's own date_finished, fall
back to first move on the step, fall back
to date_started. Same for the user. -->
<t t-set="display_dt"
t-value="step.date_finished or (step_moves and step_moves[-1].move_datetime) or step.date_started or False"/>
<t t-set="display_user"
t-value="(step.finished_by_user_id and step.finished_by_user_id.name)
or (step_moves and step_moves[-1].moved_by_user_id and step_moves[-1].moved_by_user_id.name)
or (step.started_by_user_id and step.started_by_user_id.name)
or ''"/>
<div class="fp-step-block">
<h3>
<span t-esc="(dest and dest.name) or '—'"/>
<t t-if="tank_code"> (<span t-esc="tank_code"/>)</t>
<span t-esc="step.name or '—'"/>
<t t-if="step.tank_id and step.tank_id.code">
(<span t-esc="step.tank_id.code"/>)
</t>
<t t-if="step.state == 'skipped'">
<span style="font-size: 9pt; color: #888; font-weight: normal;">— SKIPPED</span>
</t>
</h3>
<div class="fp-meta">
<strong>Part Number:</strong>
@@ -151,69 +220,72 @@
<t t-else="">
<span t-esc="(job.product_id and (job.product_id.default_code or job.product_id.name)) or ''"/>
</t>
<br/>
<strong>Moved By:</strong> <span t-esc="mv.moved_by_user_id.name"/>
<span> </span>
<strong>Time:</strong>
<span t-esc="mv.move_datetime and mv.move_datetime.strftime('%b %d, %Y %I:%M:%S %p') or ''"/>
<t t-if="display_user or display_dt">
<br/>
<strong>Moved By:</strong>
<span t-esc="display_user or '—'"/>
<span> </span>
<strong>Time:</strong>
<span t-esc="job.fp_format_local(display_dt, '%b %d, %Y %I:%M:%S %p') or '—'"/>
</t>
</div>
<!-- Captured input values for this move -->
<t t-set="captured_values_by_input"
t-value="{v.node_input_id.id: v for v in mv.transition_input_value_ids}"/>
<t t-set="prompts" t-value="False"/>
<t t-if="dest and dest.recipe_node_id">
<t t-set="prompts"
t-value="dest.recipe_node_id.input_ids.filtered(lambda i: (i.kind or 'step_input') == 'step_input').sorted('sequence')"/>
</t>
<t t-if="not prompts and mv.transition_input_value_ids">
<t t-set="prompts"
t-value="mv.transition_input_value_ids.mapped('node_input_id')"/>
</t>
<t t-if="prompts and mv.transition_input_value_ids">
<!-- Captured inputs table — only rendered
when this step has at least one
value recorded across all its moves. -->
<t t-if="step_values">
<table class="bordered">
<thead>
<tr>
<th style="width: 24%;">Name</th>
<th style="width: 30%;">Description</th>
<th style="width: 32%;">Description</th>
<th style="width: 18%;">Value</th>
<th style="width: 28%;">Recorded By</th>
<th style="width: 26%;">Recorded By</th>
</tr>
</thead>
<tbody>
<t t-foreach="prompts" t-as="inp">
<t t-set="cv" t-value="captured_values_by_input.get(inp.id)"/>
<t t-if="cv">
<t t-set="actual_str" t-value="''"/>
<t t-if="cv.value_text">
<t t-set="actual_str" t-value="cv.value_text"/>
<t t-foreach="step_values" t-as="cv">
<t t-set="inp" t-value="cv.node_input_id"/>
<t t-set="prompt_name"
t-value="(inp and inp.name) or (cv.value_text and cv.value_text.split(':')[0]) or 'Measurement'"/>
<t t-set="prompt_hint"
t-value="(inp and 'hint' in inp._fields and inp.hint) or ''"/>
<t t-set="actual_str" t-value="''"/>
<t t-if="cv.value_text">
<t t-set="actual_str" t-value="cv.value_text"/>
<!-- Strip the leading "Prompt:" prefix that
ad-hoc rows store so the Value cell
shows just the value, not the prompt
twice. -->
<t t-if="inp and inp.name and actual_str.startswith(inp.name + ':')">
<t t-set="actual_str" t-value="actual_str[len(inp.name)+1:].strip()"/>
</t>
<t t-elif="cv.value_number">
<t t-set="actual_str"
t-value="('%s %s' % (cv.value_number, (inp.target_unit if 'target_unit' in inp._fields and inp.target_unit else ''))).strip()"/>
</t>
<t t-elif="cv.value_boolean is not False">
<t t-set="actual_str" t-value="'PASS' if cv.value_boolean else 'FAIL'"/>
</t>
<t t-elif="cv.value_date">
<t t-set="actual_str" t-value="cv.value_date.strftime('%Y-%m-%d %H:%M')"/>
</t>
<tr>
<td><span t-esc="inp.name"/></td>
<td>
<t t-if="'hint' in inp._fields and inp.hint">
<span t-esc="inp.hint"/>
</t>
</td>
<td>
<strong t-esc="actual_str"/>
</td>
<td>
<span t-esc="(mv.moved_by_user_id and mv.moved_by_user_id.name) or ''"/>
</td>
</tr>
</t>
<t t-elif="cv.value_number">
<t t-set="_unit" t-value="(inp and 'target_unit' in inp._fields and inp.target_unit) or ''"/>
<t t-set="actual_str" t-value="('%s %s' % (cv.value_number, _unit)).strip()"/>
</t>
<t t-elif="cv.value_boolean is not False">
<t t-set="actual_str" t-value="'PASS' if cv.value_boolean else 'FAIL'"/>
</t>
<t t-elif="cv.value_date">
<t t-set="actual_str"
t-value="job.fp_format_local(cv.value_date, '%Y-%m-%d %H:%M')"/>
</t>
<tr>
<td><span t-esc="prompt_name"/></td>
<td>
<t t-if="prompt_hint">
<span t-esc="prompt_hint"/>
</t>
</td>
<td>
<strong t-esc="actual_str"/>
</td>
<td>
<span t-esc="(cv.move_id.moved_by_user_id and cv.move_id.moved_by_user_id.name) or ''"/>
</td>
</tr>
</t>
</tbody>
</table>
@@ -221,16 +293,22 @@
</div>
</t>
<t t-if="not moves">
<t t-if="not all_steps">
<p style="color: #888; font-style: italic;">
No move log entries yet — this job hasn't progressed
through any steps. Operators move the job forward
via the tablet or the backend Move wizard.
No steps on this job yet — operators progress the
job via Start / Finish &amp; Next on the form, or
via the tablet.
</p>
</t>
<!-- ===== CERTIFIED BY + CERT STATEMENT ===== -->
<p style="page-break-before: always;"/>
<!-- page-break-before is honoured by wkhtmltopdf
but the new page starts flush against the
header_spacing band; the spacer div below
gives the cert table breathing room so it
doesn't sit under the company logo. -->
<div style="page-break-before: always;"/>
<div style="height: 8mm;"/>
<t t-set="owner_sig" t-value="False"/>
<t t-if="'x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id">

View File

@@ -25,8 +25,14 @@
class="btn-secondary"
icon="fa-sitemap"
invisible="state == 'draft'"/>
<button name="action_open_move_wizard" type="object"
string="Move to Next Step"
<!-- Steelhead-style "Finish & Next": one click finishes
whatever's running and auto-starts the next pending
step. Falls back to starting the first step if
nothing is running yet. The classic Move wizard is
still available via the per-row Move button (used
for cross-station moves and rework / scrap). -->
<button name="action_finish_current_step" type="object"
string="Finish &amp; Next"
class="btn-primary"
icon="fa-arrow-right"
invisible="state not in ('confirmed', 'in_progress')"/>
@@ -42,6 +48,28 @@
invisible="state in ('draft', 'cancelled')"/>
</xpath>
<!-- Surface part / coating / recipe on the header so the
floor knows WHAT they're plating without diving into
Source. The "Reference Product" line in core is just
the FP-SERVICE stub from the SO — relabel it so it
doesn't compete with the real part identification. -->
<xpath expr="//field[@name='product_id']" position="attributes">
<attribute name="string">Service Product</attribute>
<attribute name="invisible">part_catalog_id</attribute>
</xpath>
<xpath expr="//field[@name='product_id']" position="after">
<field name="part_catalog_id" string="Part"/>
<field name="coating_config_id" string="Coating"/>
<field name="recipe_id" string="Process Recipe"/>
</xpath>
<!-- Show qty completed alongside total so the partial-qty
picture is visible at a glance without opening Move Log. -->
<xpath expr="//field[@name='qty']" position="after">
<field name="qty_done" string="Qty Done"/>
<field name="qty_scrapped" string="Qty Scrapped"
invisible="not qty_scrapped"/>
</xpath>
<!-- Replace the bare-bones Steps list with the action-rich
manager view. Per-row buttons mirror what an operator
sees on the tablet; Running Min ticks on every refresh
@@ -67,6 +95,14 @@
<field name="duration_expected" optional="show"/>
<field name="duration_running_minutes" string="Running Min" optional="show"/>
<field name="duration_actual" optional="show"/>
<!-- Live qty currently parked at this step. Hits
zero once everything has moved on; >0 means
the floor still has parts to process here. -->
<field name="qty_at_step" string="Qty Here" optional="show"/>
<!-- Primary action: state-aware. Pending/ready → Start,
in_progress → Finish & Next (auto-advance like
Steelhead), paused → Resume. Done / skipped /
cancelled rows show no primary. -->
<button name="button_start" type="object"
string="Start" icon="fa-play"
class="btn-link text-success"
@@ -75,26 +111,32 @@
string="Resume" icon="fa-play-circle"
class="btn-link text-success"
invisible="state != 'paused'"/>
<button name="action_finish_and_advance" type="object"
string="Finish &amp; Next" icon="fa-check-circle"
class="btn-link text-primary"
invisible="state != 'in_progress'"/>
<!-- Secondary actions — small icons only. Pause is
only relevant on a running step; Record Inputs
stays available so operators can capture
measurements without finishing the step;
Skip + Move (cross-station) tucked together. -->
<button name="button_pause" type="object"
string="Pause" icon="fa-pause"
class="btn-link text-warning"
invisible="state != 'in_progress'"/>
<button name="button_finish" type="object"
string="Finish" icon="fa-check"
class="btn-link text-primary"
invisible="state != 'in_progress'"/>
<button name="action_open_move_wizard" type="object"
string="Move" icon="fa-arrow-right"
class="btn-link"
invisible="state in ('done', 'cancelled', 'skipped')"/>
<button name="action_open_input_wizard" type="object"
string="Record Inputs" icon="fa-pencil-square-o"
string="Record" icon="fa-pencil-square-o"
class="btn-link"
invisible="state in ('cancelled', 'skipped')"/>
<button name="button_skip" type="object"
string="Skip" icon="fa-step-forward"
class="btn-link text-muted"
invisible="state not in ('pending', 'ready')"/>
<button name="action_open_move_wizard" type="object"
string="Move…" icon="fa-exchange"
class="btn-link text-muted"
invisible="state in ('done', 'cancelled', 'skipped', 'pending')"/>
</list>
</field>
</xpath>

View File

@@ -154,6 +154,14 @@ class FpJobStepInputWizard(models.TransientModel):
self.step_id.message_post(body=_(
'%(n)s step input(s) recorded by %(user)s'
) % {'n': captured, 'user': self.env.user.name})
# When the wizard was opened from "Finish & Next" we re-enter
# the step's finish-and-advance flow with a context flag so it
# skips the prompt-for-inputs branch and finishes directly.
if self.env.context.get('fp_advance_after_save'):
return self.step_id.with_context(
fp_after_inputs=True,
).action_finish_and_advance()
return {'type': 'ir.actions.act_window_close'}
@@ -207,6 +215,36 @@ class FpJobStepInputWizardLine(models.TransientModel):
for rec in self:
rec.is_authored = bool(rec.node_input_id)
# ---- Single-column value editor -----------------------------------------
# The previous wizard exposed FOUR value columns (text / number /
# yes-no / date) — operators saw 9 columns wide and got lost. We
# collapse them into one "Value" column whose widget routes to the
# right typed field based on input_type. Booleans and dates get
# their own dedicated field (still per-row) so the widget behaves
# naturally; everything else types into a single value box.
is_boolean_type = fields.Boolean(
compute='_compute_type_flags',
)
is_date_type = fields.Boolean(
compute='_compute_type_flags',
)
is_numeric_type = fields.Boolean(
compute='_compute_type_flags',
)
@api.depends('input_type')
def _compute_type_flags(self):
numeric_types = {
'number', 'temperature', 'thickness',
'time_seconds',
}
for rec in self:
it = rec.input_type or 'text'
rec.is_boolean_type = it in ('boolean', 'pass_fail')
rec.is_date_type = it == 'date'
rec.is_numeric_type = it in numeric_types
def _has_value(self):
self.ensure_one()
return any([

View File

@@ -11,38 +11,58 @@
<field name="step_id" readonly="1"/>
<field name="job_id" readonly="1"/>
</group>
<separator string="Step Inputs"/>
<separator string="Measurements"/>
<p class="text-muted" invisible="line_ids">
No authored prompts on this recipe step. Click
<strong>Add a line</strong> below to record one or
more ad-hoc measurements (operator name + value).
Authored prompts will appear here automatically once
the recipe gets `step_input` rows in the Process
Composer.
Click <strong>Add a line</strong> to record one or
more measurements for this step.
</p>
<field name="line_ids">
<list editable="bottom">
<field name="is_authored" column_invisible="1"/>
<field name="is_boolean_type" column_invisible="1"/>
<field name="is_date_type" column_invisible="1"/>
<field name="is_numeric_type" column_invisible="1"/>
<field name="name"
string="Measurement"
readonly="is_authored"
placeholder="e.g. Oven Temp, Operator Initials, Bath Reading"/>
placeholder="e.g. Oven Temp, Bath Reading, Operator Initials"/>
<field name="input_type"
string="Type"
readonly="is_authored"/>
<field name="target_unit"
string="Unit"
readonly="is_authored"
placeholder="number / text / boolean / date"
optional="show"/>
<field name="target_min" readonly="is_authored" optional="hide"/>
<field name="target_max" readonly="is_authored" optional="hide"/>
<field name="target_unit" readonly="is_authored" optional="show"/>
<field name="value_text"/>
<field name="value_number"/>
<field name="value_boolean" widget="boolean_toggle"/>
<field name="value_date"/>
<!-- Distinct column labels so the operator
reads which input matches the row's
type. List-view columns are static in
Odoo — labelling each by its purpose
removes the "four identical Value
columns" guesswork from the previous
layout. Only the cell matching the
row's type stays editable; others sit
blank. -->
<field name="value_number"
string="Number"
invisible="not is_numeric_type"/>
<field name="value_boolean"
string="Yes / No"
widget="boolean_toggle"
invisible="not is_boolean_type"/>
<field name="value_date"
string="Date / Time"
invisible="not is_date_type"/>
<field name="value_text"
string="Text"
invisible="is_numeric_type or is_boolean_type or is_date_type"/>
<field name="target_min" optional="hide"/>
<field name="target_max" optional="hide"/>
</list>
</field>
</sheet>
<footer>
<button name="action_commit" type="object"
string="Record" class="btn-primary"/>
string="Save" class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>

View File

@@ -115,7 +115,14 @@ class FpJobStepMoveWizard(models.TransientModel):
if from_step.exists():
defaults['from_step_id'] = from_step.id
defaults['job_id'] = from_step.job_id.id
defaults['qty_moved'] = int(from_step.job_id.qty or 1)
# Default to "qty currently here", not "job total". A job
# already mid-flight may have parts split across steps;
# pre-filling with the full job qty would silently let
# the operator move more than is actually parked here.
# Fall back to job qty when qty_at_step is 0 (e.g.
# opened on a fresh step before any movement).
qty_here = int(from_step.qty_at_step or 0)
defaults['qty_moved'] = qty_here or int(from_step.job_id.qty or 1)
# Next sequenced step that isn't done/cancelled
next_step = self.env['fp.job.step'].search([
('job_id', '=', from_step.job_id.id),
@@ -222,6 +229,29 @@ class FpJobStepMoveWizard(models.TransientModel):
if not self.from_step_id or not self.to_step_id:
raise UserError(_('Pick both From and To steps before moving.'))
# Partial-qty guards. The operator can't move more than is
# parked at the from-step, and zero/negative is meaningless.
# Self-loop moves (input recording) bypass the upper bound
# because they don't move qty.
if self.qty_moved <= 0:
raise UserError(_(
'Qty Moved must be at least 1. Use Skip on the step row '
'instead if no parts are being processed.'
))
is_self_loop = (self.from_step_id == self.to_step_id)
if not is_self_loop:
qty_here = int(self.from_step_id.qty_at_step or 0)
if qty_here > 0 and self.qty_moved > qty_here:
raise UserError(_(
'Cannot move %(req)s parts — only %(here)s currently '
'parked at "%(step)s". Adjust Qty Moved or split '
'across multiple moves.'
) % {
'req': self.qty_moved,
'here': qty_here,
'step': self.from_step_id.name,
})
Move = self.env['fp.job.step.move']
move = Move.create({
'job_id': self.job_id.id,

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Receiving & Inspection',
'version': '19.0.3.7.0',
'version': '19.0.3.7.1',
'category': 'Manufacturing/Plating',
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
'description': """

View File

@@ -97,7 +97,7 @@
<separator string="Photos"/>
<field name="photo_ids"
widget="many2many_tags"
options="{'no_quick_create': True, 'color_field': 'color'}"
options="{'no_quick_create': True}"
nolabel="1"
help="Attach damage / condition photos for this box. Click + to upload, then click any pill to preview."/>
<separator string="Notes"/>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.10.1.0',
'version': '19.0.10.1.3',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [

View File

@@ -66,20 +66,34 @@
<!-- ==========================================================
customer_line_description — customer-facing description
plus any populated line metadata (serial, job#, thickness).
Intended for the "Description" td in customer-facing tables.
plus serial + thickness only.
Per client request (2026-04-29): customer-facing reports
show ONLY description, serial, and thickness. Job # was
previously shown here but is internal-only — it lives on
the traveller / WO sticker / packing slip header, not on
what the customer sees. Process variant, treatment names,
and recipe codes deliberately don't render either.
========================================================== -->
<template id="customer_line_description">
<t t-if="line.x_fc_part_catalog_id">
<span t-esc="line.name"/>
<!-- Strip the "[FP-SERVICE] Plating Service" prefix Odoo's
_compute_name keeps re-prepending. fp_customer_description
lives on sale.order.line + account.move.line; for any
other model the macro might be called with we degrade to
raw line.name. QWeb's eval context doesn't expose Python
builtins like hasattr/getattr, so probe the model's method
dict via line._name + env. white-space: pre-line preserves
the estimator's line breaks. -->
<t t-set="_has_helper"
t-value="line._name in ('sale.order.line', 'account.move.line')"/>
<t t-set="_desc"
t-value="line.fp_customer_description() if _has_helper else line.name"/>
<span t-esc="_desc" style="white-space: pre-line;"/>
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
<br/>
<small>Serial: <span t-esc="line.x_fc_serial_id.name"/></small>
</t>
<t t-if="'x_fc_job_number' in line._fields and line.x_fc_job_number">
<br/>
<small>Job #: <span t-esc="line.x_fc_job_number"/></small>
</t>
<t t-if="'x_fc_thickness_id' in line._fields and line.x_fc_thickness_id">
<br/>
<small>Thickness: <span t-esc="line.x_fc_thickness_id.display_name"/></small>

View File

@@ -0,0 +1,179 @@
<h2>Hybrid: C's columns + A's card style</h2>
<p class="subtitle">Status columns, but each project is a full card with progress bar, %, hours, and task count.</p>
<style>
.preview-page { background:#f3f4f6; padding:18px; border-radius:8px; min-height:340px; font-size:12px;}
.preview-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:14px; color:#6b7280;}
.preview-head .crumbs { display:flex; gap:6px; align-items:center; color:#374151; font-weight:500;}
.preview-head .controls { display:flex; gap:6px;}
.ctrl { background:white; border:1px solid #d8dadd; padding:3px 10px; border-radius:6px; font-size:11px;}
.pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:10px; font-weight:600;}
.pill.green { background:#dcfce7; color:#166534;}
.pill.blue { background:#dbeafe; color:#1d4ed8;}
.pill.amber { background:#fef3c7; color:#92400e;}
.pill.gray { background:#e5e7eb; color:#374151;}
.cols { display:grid; grid-template-columns: 1fr 1fr 1fr; gap:14px;}
.col { background:transparent; }
.col-head { display:flex; justify-content:space-between; align-items:center; padding:0 4px 8px; border-bottom:2px solid #e5e7eb; margin-bottom:10px;}
.col-head .label { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:#374151; display:flex; align-items:center; gap:6px;}
.dot { width:8px; height:8px; border-radius:99px;}
.dot.green { background:#22c55e;}
.dot.amber { background:#f59e0b;}
.dot.gray { background:#9ca3af;}
.col-count { font-size:10px; font-weight:700; color:#6b7280; background:#e5e7eb; padding:2px 7px; border-radius:99px;}
/* card pulled from option A */
.pcard { background:white; border:1px solid #d8dadd; border-radius:10px; padding:11px; margin-bottom:8px; box-shadow:0 1px 0 rgba(15,23,42,.02);}
.pcard:hover { border-color:#93c5fd; box-shadow:0 2px 8px rgba(59,130,246,.08);}
.pcard .ptitle { font-weight:600; color:#0f172a; margin-bottom:4px; font-size:12px; line-height:1.3;}
.pcard .pmeta-row { display:flex; justify-content:space-between; align-items:center; color:#6b7280; font-size:10px; margin-bottom:6px;}
.pcard .pbar { height:5px; background:#e5e7eb; border-radius:99px; overflow:hidden; margin-bottom:7px;}
.pcard .pbar > span { display:block; height:100%; background:#22c55e;}
.pcard .pbar.amber > span { background:#f59e0b;}
.pcard .pfooter { display:flex; justify-content:space-between; align-items:center; font-size:10px; color:#6b7280;}
.pcard .pfooter .stats { display:flex; gap:5px;}
.empty { color:#9ca3af; font-size:11px; text-align:center; padding:24px 0; background:white; border:1px dashed #d8dadd; border-radius:10px;}
</style>
<div class="preview-page">
<div class="preview-head">
<span class="crumbs">🏠 / Projects</span>
<div class="controls">
<span class="ctrl">🔎 Search…</span>
<span class="ctrl">Group: Status ▾</span>
<span class="ctrl">Sort: Name ▾</span>
</div>
</div>
<div class="cols">
<!-- ACTIVE -->
<div class="col">
<div class="col-head">
<span class="label"><span class="dot green"></span> Active</span>
<span class="col-count">2</span>
</div>
<div class="pcard">
<div class="ptitle">S29824</div>
<div class="pmeta-row">
<span>Westin Healthcare</span>
<span>62%</span>
</div>
<div class="pbar"><span style="width:62%"></span></div>
<div class="pfooter">
<span class="stats"><span class="pill blue">50 tasks</span><span class="pill green">31 done</span></span>
<span>42.5 / 65h</span>
</div>
</div>
<div class="pcard">
<div class="ptitle">S29824 - Internal</div>
<div class="pmeta-row">
<span>Internal QA</span>
<span>0%</span>
</div>
<div class="pbar amber"><span style="width:8%"></span></div>
<div class="pfooter">
<span class="stats"><span class="pill blue">1 task</span></span>
<span></span>
</div>
</div>
</div>
<!-- IDLE -->
<div class="col">
<div class="col-head">
<span class="label"><span class="dot amber"></span> Idle</span>
<span class="col-count">3</span>
</div>
<div class="pcard">
<div class="ptitle">Customer Care</div>
<div class="pmeta-row">
<span>No tasks yet</span>
<span></span>
</div>
<div class="pbar"><span style="width:0%"></span></div>
<div class="pfooter">
<span class="stats"><span class="pill gray">0 tasks</span></span>
<span></span>
</div>
</div>
<div class="pcard">
<div class="ptitle">Field Service</div>
<div class="pmeta-row">
<span>No tasks yet</span>
<span></span>
</div>
<div class="pbar"><span style="width:0%"></span></div>
<div class="pfooter">
<span class="stats"><span class="pill gray">0 tasks</span></span>
<span></span>
</div>
</div>
<div class="pcard">
<div class="ptitle">Internal</div>
<div class="pmeta-row">
<span>No tasks yet</span>
<span></span>
</div>
<div class="pbar"><span style="width:0%"></span></div>
<div class="pfooter">
<span class="stats"><span class="pill gray">0 tasks</span></span>
<span></span>
</div>
</div>
</div>
<!-- DONE -->
<div class="col">
<div class="col-head">
<span class="label"><span class="dot gray"></span> Done</span>
<span class="col-count">0</span>
</div>
<div class="empty">Completed projects will appear here.</div>
</div>
</div>
</div>
<div class="section" style="margin-top:22px">
<h3>Open question: how do we decide which column?</h3>
<p class="subtitle">Same card design either way; this just determines column placement.</p>
<div class="options" style="margin-top:12px">
<div class="option" data-choice="grouping-activity" onclick="toggleSelect(this)">
<div class="letter">1</div>
<div class="content">
<h3>By activity (computed)</h3>
<p><b>Active</b> = at least one open task. <b>Idle</b> = no open tasks but project is still open. <b>Done</b> = project is archived/closed. No new fields needed.</p>
</div>
</div>
<div class="option" data-choice="grouping-status" onclick="toggleSelect(this)">
<div class="letter">2</div>
<div class="content">
<h3>By project status field</h3>
<p>Use Odoo's <code>last_update_status</code> (On Track / At Risk / Off Track / On Hold / Done). Five columns is too many for a portal — we'd collapse into 3 buckets.</p>
</div>
</div>
<div class="option" data-choice="grouping-stage" onclick="toggleSelect(this)">
<div class="letter">3</div>
<div class="content">
<h3>By a custom field on project</h3>
<p>Add <code>x_fc_portal_status</code> with three values you control (e.g. Active/Idle/Done). Most flexible, but someone has to maintain it.</p>
</div>
</div>
</div>
</div>
<p class="subtitle" style="margin-top:14px">My recommendation: <b>Option 1 — by activity</b>. Zero new fields, columns reflect reality automatically, and it matches what the screenshot already shows ("S29824 has 50 tasks, others have 0").</p>

View File

@@ -0,0 +1,151 @@
<h2>Pick a layout direction for /my/projects</h2>
<p class="subtitle">Click a card to select. We'll iterate on the chosen direction next.</p>
<style>
.preview-page { background: #f3f4f6; padding: 14px; border-radius: 6px; min-height: 240px; font-size: 12px; }
.preview-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; color:#6b7280;}
.preview-head .crumbs { display:flex; gap:6px; align-items:center;}
.pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:10px; font-weight:600;}
.pill.green { background:#dcfce7; color:#166534;}
.pill.blue { background:#dbeafe; color:#1d4ed8;}
.pill.amber { background:#fef3c7; color:#92400e;}
.pill.gray { background:#e5e7eb; color:#374151;}
/* Option A: card grid */
.a-grid { display:grid; grid-template-columns: 1fr 1fr; gap:8px;}
.a-card { background:white; border:1px solid #d8dadd; border-radius:8px; padding:10px; }
.a-card .a-title { font-weight:600; color:#111827; margin-bottom:4px;}
.a-card .a-meta { display:flex; justify-content:space-between; align-items:center; color:#6b7280; font-size:10px;}
.a-bar { height:4px; background:#e5e7eb; border-radius:99px; margin-top:6px; overflow:hidden;}
.a-bar > span { display:block; height:100%; background:#22c55e;}
/* Option B: enhanced rows */
.b-row { display:flex; align-items:center; padding:9px 12px; background:white; border:1px solid #d8dadd; border-radius:6px; margin-bottom:4px;}
.b-row .icon { width:28px; height:28px; border-radius:6px; background:#dbeafe; display:flex;align-items:center;justify-content:center; color:#1d4ed8; font-weight:700; margin-right:10px; font-size:11px;}
.b-row .name { flex-grow:1; font-weight:600; color:#111827;}
.b-row .stats { display:flex; gap:6px;}
.b-row .barwrap { width:80px; height:4px; background:#e5e7eb; border-radius:99px; margin-right:8px;}
.b-row .barwrap > span { display:block; height:100%; background:#22c55e;}
/* Option C: kanban columns */
.c-cols { display:grid; grid-template-columns: 1fr 1fr 1fr; gap:8px;}
.c-col { background:#fafafa; border:1px solid #e5e7eb; border-radius:8px; padding:8px;}
.c-col h4 { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:#6b7280; margin:0 0 8px;}
.c-card { background:white; border:1px solid #d8dadd; border-radius:6px; padding:8px; margin-bottom:6px; font-size:11px;}
.c-card .ct { font-weight:600; color:#111827; margin-bottom:2px;}
.c-card .cm { color:#6b7280; font-size:10px;}
</style>
<div class="cards">
<div class="card" data-choice="a" onclick="toggleSelect(this)">
<div class="card-image">
<div class="preview-page">
<div class="preview-head">
<span class="crumbs">🏠 / Projects</span>
<span>Sort: Name ▾</span>
</div>
<div class="a-grid">
<div class="a-card">
<div class="a-title">S29824</div>
<div class="a-meta"><span class="pill blue">50 tasks</span><span>62%</span></div>
<div class="a-bar"><span style="width:62%"></span></div>
</div>
<div class="a-card">
<div class="a-title">Customer Care</div>
<div class="a-meta"><span class="pill gray">0 tasks</span><span></span></div>
<div class="a-bar"><span style="width:0%"></span></div>
</div>
<div class="a-card">
<div class="a-title">Field Service</div>
<div class="a-meta"><span class="pill gray">0 tasks</span><span></span></div>
<div class="a-bar"><span style="width:0%"></span></div>
</div>
<div class="a-card">
<div class="a-title">S29824 - Internal</div>
<div class="a-meta"><span class="pill blue">1 task</span><span>0%</span></div>
<div class="a-bar"><span style="width:0%"></span></div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>A — Card grid</h3>
<p>Each project is a card with name, task count, % complete, and a tiny progress bar. 2-up on desktop, 1-up on mobile. Most "modern dashboard" feel.</p>
</div>
</div>
<div class="card" data-choice="b" onclick="toggleSelect(this)">
<div class="card-image">
<div class="preview-page">
<div class="preview-head">
<span class="crumbs">🏠 / Projects</span>
<span>Sort: Name ▾</span>
</div>
<div class="b-row">
<div class="icon">S2</div>
<div class="name">S29824</div>
<div class="barwrap"><span style="width:62%"></span></div>
<div class="stats"><span class="pill green">62%</span><span class="pill blue">50</span></div>
</div>
<div class="b-row">
<div class="icon" style="background:#fef3c7;color:#92400e">CC</div>
<div class="name">Customer Care</div>
<div class="barwrap"><span style="width:0%"></span></div>
<div class="stats"><span class="pill gray">0%</span><span class="pill gray">0</span></div>
</div>
<div class="b-row">
<div class="icon" style="background:#dcfce7;color:#166534">FS</div>
<div class="name">Field Service</div>
<div class="barwrap"><span style="width:0%"></span></div>
<div class="stats"><span class="pill gray">0%</span><span class="pill gray">0</span></div>
</div>
<div class="b-row">
<div class="icon">S2</div>
<div class="name">S29824 - Internal</div>
<div class="barwrap"><span style="width:0%"></span></div>
<div class="stats"><span class="pill amber">0%</span><span class="pill blue">1</span></div>
</div>
</div>
</div>
<div class="card-body">
<h3>B — Enhanced rows</h3>
<p>Same vertical list, but each row gets an avatar/initials chip, inline progress bar, and badge stats. Closest to the current page; quickest to scan with many projects.</p>
</div>
</div>
<div class="card" data-choice="c" onclick="toggleSelect(this)">
<div class="card-image">
<div class="preview-page">
<div class="preview-head">
<span class="crumbs">🏠 / Projects</span>
<span>Group: Status ▾</span>
</div>
<div class="c-cols">
<div class="c-col">
<h4>● Active</h4>
<div class="c-card"><div class="ct">S29824</div><div class="cm">50 tasks · 62%</div></div>
<div class="c-card"><div class="ct">S29824 - Internal</div><div class="cm">1 task · 0%</div></div>
</div>
<div class="c-col">
<h4>● Idle</h4>
<div class="c-card"><div class="ct">Customer Care</div><div class="cm">0 tasks</div></div>
<div class="c-card"><div class="ct">Field Service</div><div class="cm">0 tasks</div></div>
<div class="c-card"><div class="ct">Internal</div><div class="cm">0 tasks</div></div>
</div>
<div class="c-col">
<h4>● Done</h4>
<div style="color:#9ca3af;font-size:10px;text-align:center;padding:16px 0">Nothing here yet</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>C — Status columns (kanban-ish)</h3>
<p>Projects grouped into columns by activity (Active / Idle / Done). Visually striking but unusual for a customer portal where users mainly browse to drill into one project.</p>
</div>
</div>
</div>
<p class="subtitle" style="margin-top:18px">My recommendation: <b>A — Card grid</b>. It's the most "modern" feel you asked for, scales gracefully to a few or many projects, and gives room for the data we already compute (task counts, % done, hours). B is a strong runner-up if you have lots of projects (20+); C feels overengineered for a portal landing page.</p>

View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Implementing in terminal — check back here only if I push another mockup.</p>
</div>

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1777430092232}

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Project Portal',
'version': '19.0.3.0.0',
'version': '19.0.4.1.1',
'category': 'Project',
'summary': 'Customer portal hierarchy + task creation, plus surfaces logged timesheets on the portal',
'description': """
@@ -17,6 +17,12 @@
'views/portal_templates.xml',
'views/wizard_views.xml',
],
'assets': {
'web.assets_frontend': [
'fusion_project_portal/static/src/scss/portal_projects.scss',
'fusion_project_portal/static/src/js/portal_search_live.js',
],
},
'installable': True,
'application': False,
'license': 'LGPL-3',

View File

@@ -1,3 +1,5 @@
from collections import defaultdict
from odoo import http, _
from odoo.exceptions import AccessError, MissingError
from odoo.http import request
@@ -14,6 +16,176 @@ class FusionProjectCustomerPortal(ProjectCustomerPortal):
}
return values
def _fp_project_card_data(self, projects):
"""Return {project_id: dict} with the rich stats the redesigned card needs.
Aggregations are done in two grouped reads (tasks + timesheets) so the
page stays cheap regardless of how many projects/tasks the user owns.
"""
if not projects:
return {}
Task = request.env['project.task'].sudo()
AAL = request.env['account.analytic.line'].sudo()
done_states = ('1_done', '03_approved')
has_alloc = 'allocated_hours' in Task._fields
has_effective = 'effective_hours' in Task._fields
task_data = defaultdict(lambda: {
'open': 0, 'done': 0, 'total': 0,
'alloc': 0.0, 'last_activity': False,
'assignees': set(),
'task_partners': set(),
})
task_fields = ['project_id', 'state', 'write_date', 'user_ids', 'partner_id']
if has_alloc:
task_fields.append('allocated_hours')
tasks = Task.search_read(
[('project_id', 'in', projects.ids), ('is_template', '=', False)],
task_fields,
)
for t in tasks:
pid = t['project_id'][0] if t.get('project_id') else False
if not pid:
continue
d = task_data[pid]
d['total'] += 1
if t['state'] in done_states:
d['done'] += 1
else:
d['open'] += 1
if has_alloc and t.get('allocated_hours'):
d['alloc'] += t['allocated_hours']
wd = t.get('write_date')
if wd and (not d['last_activity'] or wd > d['last_activity']):
d['last_activity'] = wd
for uid in t.get('user_ids') or []:
d['assignees'].add(uid)
if t.get('partner_id'):
d['task_partners'].add((t['partner_id'][0], t['partner_id'][1]))
# Timesheet hours per project (effective hours come from timesheets).
spent_by_project = defaultdict(float)
if has_effective:
ts_groups = AAL.read_group(
[('project_id', 'in', projects.ids)],
['project_id', 'unit_amount:sum'],
['project_id'],
)
for g in ts_groups:
if g.get('project_id'):
spent_by_project[g['project_id'][0]] = g.get('unit_amount', 0.0) or 0.0
# Resolve assignee names + avatars in one batch.
all_user_ids = set()
for d in task_data.values():
all_user_ids |= d['assignees']
users_by_id = {}
if all_user_ids:
for u in request.env['res.users'].sudo().browse(list(all_user_ids)):
users_by_id[u.id] = {
'id': u.id,
'name': u.name,
'initials': ''.join([p[:1].upper() for p in (u.name or '?').split()[:2]]) or '?',
}
result = {}
for project in projects:
d = task_data.get(project.id, {
'open': 0, 'done': 0, 'total': 0,
'alloc': 0.0, 'last_activity': False,
'assignees': set(), 'task_partners': set(),
})
spent = spent_by_project.get(project.id, 0.0)
pct = round(100.0 * d['done'] / d['total'], 0) if d['total'] else 0
if not project.active:
bucket = 'done'
elif d['open'] > 0:
bucket = 'active'
else:
bucket = 'idle'
assignees = [users_by_id[uid] for uid in d['assignees'] if uid in users_by_id]
assignees.sort(key=lambda u: u['name'])
# Customer label: prefer the project's customer; fall back to task
# customers when the project has none. If multiple distinct task
# customers, show a count rather than picking one arbitrarily.
if project.partner_id:
partner_name = project.partner_id.display_name
elif len(d['task_partners']) == 1:
partner_name = next(iter(d['task_partners']))[1]
elif len(d['task_partners']) > 1:
partner_name = _('%s customers') % len(d['task_partners'])
else:
partner_name = ''
result[project.id] = {
'open_count': d['open'],
'done_count': d['done'],
'total_count': d['total'],
'pct': pct,
'alloc_hours': d['alloc'],
'spent_hours': spent,
'last_activity': d['last_activity'] or project.write_date,
'assignees': assignees,
'bucket': bucket,
'partner_name': partner_name,
}
return result
def _prepare_searchbar_sortings(self):
sortings = super()._prepare_searchbar_sortings()
# Add an "activity" SQL sort. We don't add task_count / pct here because
# those are non-stored compute fields and would break the SQL ORDER BY.
# The redesigned page sorts those client-side.
sortings['activity'] = {'label': _('Last Activity'), 'order': 'write_date desc'}
return sortings
@http.route(['/my/projects', '/my/projects/page/<int:page>'],
type='http', auth='user', website=True)
def portal_my_projects(self, page=1, date_begin=None, date_end=None, sortby=None, **kw):
from odoo.addons.portal.controllers.portal import pager as portal_pager
Project = request.env['project.project']
values = self._prepare_portal_layout_values()
domain = self._prepare_project_domain()
searchbar_sortings = self._prepare_searchbar_sortings()
if not sortby or sortby not in searchbar_sortings:
sortby = 'name'
order = searchbar_sortings[sortby]['order']
if date_begin and date_end:
domain += [('create_date', '>', date_begin), ('create_date', '<=', date_end)]
project_count = Project.search_count(domain)
# Bigger page size than core's default — the redesign is meant to scan
# everything you have at a glance, not paginate two-at-a-time.
per_page = max(self._items_per_page, 40)
pager = portal_pager(
url='/my/projects',
url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby},
total=project_count, page=page, step=per_page,
)
projects = Project.search(domain, order=order, limit=per_page, offset=pager['offset'])
request.session['my_projects_history'] = projects.ids[:100]
values.update({
'date': date_begin,
'date_end': date_end,
'projects': projects,
'page_name': 'project',
'default_url': '/my/projects',
'pager': pager,
'searchbar_sortings': searchbar_sortings,
'sortby': sortby,
'fp_project_cards': self._fp_project_card_data(projects),
'fp_groupby': (kw.get('groupby') or 'status').lower(),
})
return request.render('fusion_project_portal.portal_my_projects', values)
def _project_get_page_view_values(self, project, access_token, page=1, date_begin=None,
date_end=None, sortby=None, search=None,
search_in='content', groupby=None, **kwargs):
@@ -81,10 +253,37 @@ class FusionProjectCustomerPortal(ProjectCustomerPortal):
walk(c.id, depth + 1)
walk(task.id, 0)
done_states = ('1_done', '03_approved')
total = len(descendants)
done = sum(1 for d in descendants if d.state in done_states)
allocated_field = 'allocated_hours' if 'allocated_hours' in Task._fields else (
'planned_hours' if 'planned_hours' in Task._fields else None
)
has_effective = 'effective_hours' in Task._fields
alloc_by_id = {}
spent_by_id = {}
for d in descendants:
alloc_by_id[d.id] = float(getattr(d, allocated_field, 0.0) or 0.0) if allocated_field else 0.0
spent_by_id[d.id] = float(d.effective_hours or 0.0) if has_effective else 0.0
total_alloc = sum(alloc_by_id.values())
total_spent = sum(spent_by_id.values())
values['fp_task_descendants'] = descendants
values['fp_task_depth_inside'] = depth_map
values['fp_can_create_task'] = self._fp_can_create_task(task.project_id)
values['fp_can_change_state'] = self._fp_can_change_state(task)
values['fp_priority_options'] = request.env['project.task']._fields['priority']._description_selection(request.env)
values['fp_done_states'] = done_states
values['fp_descendant_total'] = total
values['fp_descendant_done'] = done
values['fp_descendant_pct'] = round(100.0 * done / total, 1) if total else 0.0
values['fp_descendant_alloc_hours'] = total_alloc
values['fp_descendant_spent_hours'] = total_spent
values['fp_alloc_by_id'] = alloc_by_id
values['fp_spent_by_id'] = spent_by_id
timesheets = request.env['account.analytic.line'].sudo().search(
[('task_id', '=', task.id)],
order='date desc, id desc',
@@ -119,6 +318,43 @@ class FusionProjectCustomerPortal(ProjectCustomerPortal):
], limit=1)
return bool(follower)
def _fp_can_change_state(self, task):
"""Permissive gate for the customer status actions on a task page.
A portal user who can VIEW the task should be able to flip its state
(Request Changes / Approve / Mark Done). _fp_can_create_task is too
strict — it only passes the project's customer or a project follower.
Customers are often added to a single task as the task partner or as a
task follower, never on the project.
"""
user = request.env.user
if not task or not task.exists():
return False
if not user or user.id == request.env.ref('base.public_user').id:
return False
if user._is_internal():
return True
# Project-level checks (covers most customers)
if self._fp_can_create_task(task.project_id):
return True
partner = user.partner_id
if not partner:
return False
# Task-level partner / commercial-partner family
if task.partner_id and (
partner == task.partner_id
or partner.parent_id == task.partner_id
or partner.commercial_partner_id == task.partner_id.commercial_partner_id
):
return True
# Task followers
follower = request.env['mail.followers'].sudo().search_count([
('res_model', '=', 'project.task'),
('res_id', '=', task.id),
('partner_id', '=', partner.id),
], limit=1)
return bool(follower)
@http.route(
['/my/projects/<int:project_id>/task/<int:task_id>/state'],
type='http', auth='user', website=True, methods=['POST'],
@@ -130,9 +366,6 @@ class FusionProjectCustomerPortal(ProjectCustomerPortal):
except (AccessError, MissingError):
return request.redirect('/my')
if not self._fp_can_create_task(project_sudo):
return request.redirect(f'/my/projects/{project_id}/task/{task_id}')
task = request.env['project.task'].sudo().search([
('id', '=', task_id),
('project_id', '=', project_id),
@@ -140,6 +373,9 @@ class FusionProjectCustomerPortal(ProjectCustomerPortal):
if not task:
return request.redirect(f'/my/projects/{project_id}')
if not self._fp_can_change_state(task):
return request.redirect(f'/my/projects/{project_id}/task/{task_id}')
new_state = post.get('state')
allowed = ['01_in_progress', '02_changes_requested', '03_approved', '1_done']
if new_state not in allowed:

View File

@@ -0,0 +1,392 @@
/** @odoo-module **/
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
export class PortalSearchLive extends Interaction {
static selector = ".o_portal_wrap";
dynamicContent = {
".fp_subtask_search": {
"t-on-input": this.debounced(this.onSubtaskInput, 80),
"t-on-keydown": this.onSubtaskKeydown,
},
".o_portal_search_panel input[name='search']": {
"t-on-input": this.debounced(this.onGlobalInput, 120),
},
};
setup() {
this._lastQ = new WeakMap();
}
start() {
// Apply once on load (handles server-rendered ?search=... and the empty initial state)
for (const input of this.el.querySelectorAll(".fp_subtask_search")) {
this.filterCard(input);
}
const g = this.el.querySelector(".o_portal_search_panel input[name='search']");
if (g && g.value) {
this.filterTables(g);
}
}
onSubtaskInput(ev) {
this.filterCard(ev.target);
}
onSubtaskKeydown(ev) {
if (ev.key === "Enter") {
ev.preventDefault();
this.filterCard(ev.target);
} else if (ev.key === "Escape") {
ev.target.value = "";
this.filterCard(ev.target);
}
}
onGlobalInput(ev) {
this.filterTables(ev.target);
}
filterCard(input) {
const card = input.closest(".card");
if (!card) {
return;
}
const list = card.querySelector("ul.list-group");
if (!list) {
return;
}
const q = (input.value || "").trim().toLowerCase();
if (this._lastQ.get(input) === q) {
return;
}
this._lastQ.set(input, q);
let visible = 0;
for (const li of list.children) {
if (li.querySelector("form")) {
// Skip inline "+ subtask" forms; show/hide them with their parent row.
continue;
}
if (!q) {
li.style.display = "";
li.classList.remove("d-none");
visible += 1;
continue;
}
const text = (li.textContent || "").toLowerCase();
const match = text.indexOf(q) !== -1;
li.style.display = match ? "" : "none";
li.classList.toggle("d-none", !match);
if (match) {
visible += 1;
}
}
// Toggle a "no matches" empty hint
let hint = card.querySelector(".fp_subtask_no_match");
if (q && visible === 0) {
if (!hint) {
hint = document.createElement("div");
hint.className = "fp_subtask_no_match card-body text-muted small";
hint.textContent = "No sub-tasks match your search.";
list.insertAdjacentElement("afterend", hint);
}
hint.style.display = "";
} else if (hint) {
hint.style.display = "none";
}
}
filterTables(input) {
const q = (input.value || "").trim().toLowerCase();
const tables = this.el.querySelectorAll("table.table, table.o_portal_my_doc_table");
tables.forEach((table) => {
table.querySelectorAll("tbody").forEach((tbody) => {
let visibleTaskRows = 0;
let groupHeaderRow = null;
tbody.querySelectorAll("tr").forEach((row) => {
const isGroupHeader =
row.getAttribute("name") === "grouped_tasks_groupby_columns" ||
row.classList.contains("table-light");
if (isGroupHeader) {
if (groupHeaderRow !== null) {
groupHeaderRow.style.display = visibleTaskRows ? "" : "none";
}
groupHeaderRow = row;
visibleTaskRows = 0;
return;
}
if (!q) {
row.style.display = "";
visibleTaskRows += 1;
return;
}
const text = (row.textContent || "").toLowerCase();
const match = text.indexOf(q) !== -1;
row.style.display = match ? "" : "none";
if (match) {
visibleTaskRows += 1;
}
});
if (groupHeaderRow !== null) {
groupHeaderRow.style.display = visibleTaskRows ? "" : "none";
}
});
});
}
}
registry.category("public.interactions").add("fusion_project_portal.portal_search_live", PortalSearchLive);
// ---------------------------------------------------------------------------
// /my/projects: client-side search + sort + group on the redesigned card grid.
// All projects are server-rendered into the page; this just toggles visibility
// and re-orders existing DOM nodes — no RPC.
// ---------------------------------------------------------------------------
const SORT_KEYS = {
name: (el) => (el.dataset.name || "").toLowerCase(),
pct: (el) => -parseFloat(el.dataset.pct || "0"),
tasks: (el) => -parseInt(el.dataset.tasks || "0", 10),
activity: (el) => {
const v = el.dataset.activity || "";
// sort newest first; missing values go last
return v ? `0${v.split("").reverse().join("")}` : "z";
},
};
const SORT_LABELS = {
name: "Name",
pct: "% Complete",
tasks: "Task Count",
activity: "Last Activity",
};
export class FusionProjectsPage extends Interaction {
static selector = ".fp_projects_page";
dynamicContent = {
".fp_projects_search": {
"t-on-input": this.debounced(this.onSearchInput, 80),
"t-on-keydown": this.onSearchKeydown,
},
".fp_projects_group_picker .btn": {
"t-on-click.prevent.withTarget": this.onGroupClick,
},
".fp_projects_sort_picker .dropdown-item": {
"t-on-click.prevent.withTarget": this.onSortClick,
},
};
setup() {
this._sortKey = "name";
this._group = this.el.dataset.defaultGroup || "status";
this._cards = Array.from(this.el.querySelectorAll(".fp_project_card"));
}
start() {
this._applyGroup(this._group, /*initial=*/ true);
this._applySort();
}
onSearchInput(ev) {
this._applyFilter((ev.target.value || "").trim().toLowerCase());
}
onSearchKeydown(ev) {
if (ev.key === "Escape") {
ev.preventDefault();
ev.target.value = "";
this._applyFilter("");
}
}
onGroupClick(ev, currentTargetEl) {
const group = currentTargetEl.dataset.group;
if (!group || group === this._group) {
return;
}
this.el.querySelectorAll(".fp_projects_group_picker .btn").forEach((b) =>
b.classList.toggle("active", b === currentTargetEl)
);
this._applyGroup(group);
this._applySort();
// Re-apply the active search after re-grouping
const searchEl = this.el.querySelector(".fp_projects_search");
this._applyFilter((searchEl && searchEl.value || "").trim().toLowerCase());
}
onSortClick(ev, currentTargetEl) {
const key = currentTargetEl.dataset.sort;
if (!key || !(key in SORT_KEYS)) {
return;
}
this._sortKey = key;
const labelEl = this.el.querySelector(".fp_sort_label");
if (labelEl) {
labelEl.textContent = SORT_LABELS[key];
}
this.el.querySelectorAll(".fp_projects_sort_picker .dropdown-item").forEach((item) =>
item.classList.toggle("active", item === currentTargetEl)
);
this._applySort();
}
_applyFilter(q) {
let visibleTotal = 0;
const perColumn = new Map();
for (const card of this._cards) {
const matches =
!q ||
(card.dataset.name || "").toLowerCase().includes(q) ||
(card.dataset.customer || "").toLowerCase().includes(q);
card.classList.toggle("d-none", !matches);
if (matches) {
visibleTotal += 1;
const col = card.closest(".fp_projects_col");
const key = col ? (col.dataset.bucket || col.dataset.group || "") : "";
perColumn.set(key, (perColumn.get(key) || 0) + 1);
}
}
// Update column counts and toggle column-empty fallback
for (const col of this.el.querySelectorAll(".fp_projects_col")) {
const key = col.dataset.bucket || col.dataset.group || "";
const n = perColumn.get(key) || 0;
const countEl = col.querySelector(".fp_col_count");
if (countEl) {
countEl.textContent = String(n);
}
const empty = col.querySelector(".fp_col_empty");
if (empty) {
empty.classList.toggle("d-none", n > 0);
}
}
const noMatch = this.el.querySelector(".fp_projects_no_match");
if (noMatch) {
noMatch.classList.toggle("d-none", visibleTotal !== 0 || !q);
}
}
_applyGroup(group, initial = false) {
this._group = group;
this.el.dataset.currentGroup = group;
const cols = this.el.querySelector(".fp_projects_cols");
if (!cols) {
return;
}
if (group === "status") {
// Restore the original 3 status columns rendered server-side
this._restoreStatusColumns(cols);
return;
}
if (group === "none") {
this._renderFlat(cols);
return;
}
if (group === "customer") {
this._renderByCustomer(cols);
return;
}
}
_restoreStatusColumns(cols) {
// Cache the original markup once so we can flip back without RPC
if (!this._originalCols) {
this._originalCols = cols.innerHTML;
} else {
cols.innerHTML = this._originalCols;
this._cards = Array.from(this.el.querySelectorAll(".fp_project_card"));
}
}
_renderFlat(cols) {
if (!this._originalCols) {
this._originalCols = cols.innerHTML;
}
const cards = Array.from(this.el.querySelectorAll(".fp_project_card"));
cols.innerHTML = "";
const col = this._buildColumn("All projects", "gray", cards.length);
col.dataset.group = "all";
const body = col.querySelector(".fp_projects_col_body");
for (const card of cards) {
body.appendChild(card);
}
cols.appendChild(col);
this._cards = cards;
}
_renderByCustomer(cols) {
if (!this._originalCols) {
this._originalCols = cols.innerHTML;
}
const cards = Array.from(this.el.querySelectorAll(".fp_project_card"));
const groups = new Map();
for (const card of cards) {
const key = (card.dataset.customer || "Unassigned").trim() || "Unassigned";
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(card);
}
const orderedKeys = Array.from(groups.keys()).sort((a, b) => {
if (a === "Unassigned") return 1;
if (b === "Unassigned") return -1;
return a.localeCompare(b);
});
cols.innerHTML = "";
for (const key of orderedKeys) {
const items = groups.get(key);
const col = this._buildColumn(key, "gray", items.length);
col.dataset.group = key;
const body = col.querySelector(".fp_projects_col_body");
for (const card of items) {
body.appendChild(card);
}
cols.appendChild(col);
}
this._cards = cards;
}
_buildColumn(label, dot, count) {
const col = document.createElement("div");
col.className = "fp_projects_col";
col.innerHTML = `
<div class="fp_projects_col_head">
<span class="fp_col_label"><span class="fp_dot fp_dot_${dot}"></span>${this._escape(label)}</span>
<span class="fp_col_count">${count}</span>
</div>
<div class="fp_projects_col_body"></div>
`;
return col;
}
_escape(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
_applySort() {
const fn = SORT_KEYS[this._sortKey] || SORT_KEYS.name;
for (const col of this.el.querySelectorAll(".fp_projects_col")) {
const body = col.querySelector(".fp_projects_col_body");
if (!body) continue;
const cards = Array.from(body.querySelectorAll(".fp_project_card"));
cards.sort((a, b) => {
const av = fn(a);
const bv = fn(b);
if (av < bv) return -1;
if (av > bv) return 1;
return 0;
});
for (const c of cards) {
body.appendChild(c);
}
}
}
}
registry.category("public.interactions").add("fusion_project_portal.projects_page", FusionProjectsPage);

View File

@@ -0,0 +1,263 @@
// Fusion portal: redesigned /my/projects page
// Tokens are wrapped in CSS custom properties so a future portal dark-mode
// pass can flip the surface colors without rewriting any rules.
$fp-page-bg: var(--fp-page-bg, #f3f4f6);
$fp-card-bg: var(--fp-card-bg, #ffffff);
$fp-border: var(--fp-border, #d8dadd);
$fp-text: var(--fp-text, #0f172a);
$fp-muted: var(--fp-muted, #6b7280);
$fp-track: var(--fp-track, #e5e7eb);
$fp-blue-bg: var(--fp-blue-bg, #dbeafe);
$fp-blue-fg: var(--fp-blue-fg, #1d4ed8);
$fp-green-bg: var(--fp-green-bg, #dcfce7);
$fp-green-fg: var(--fp-green-fg, #166534);
$fp-amber-bg: var(--fp-amber-bg, #fef3c7);
$fp-amber-fg: var(--fp-amber-fg, #92400e);
$fp-cyan-bg: var(--fp-cyan-bg, #cffafe);
$fp-cyan-fg: var(--fp-cyan-fg, #155e75);
$fp-gray-bg: var(--fp-gray-bg, #e5e7eb);
$fp-gray-fg: var(--fp-gray-fg, #374151);
$fp-bar-high: var(--fp-bar-high, #22c55e);
$fp-bar-mid: var(--fp-bar-mid, #3b82f6);
$fp-bar-low: var(--fp-bar-low, #f59e0b);
$fp-radius: 10px;
$fp-radius-sm: 6px;
.fp_projects_page {
.fp_projects_header {
h3 {
color: $fp-text;
}
.fp_projects_search_wrap {
min-width: 220px;
max-width: 280px;
}
}
// 3-column status grid
.fp_projects_cols {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
@media (max-width: 992px) {
grid-template-columns: 1fr;
gap: 18px;
}
}
// Flat / customer-grouped variants reuse a single column
&[data-current-group="none"] .fp_projects_cols,
&[data-current-group="customer"] .fp_projects_cols {
grid-template-columns: 1fr;
}
.fp_projects_col {
min-width: 0;
.fp_projects_col_head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 4px 8px;
border-bottom: 2px solid $fp-track;
margin-bottom: 10px;
.fp_col_label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $fp-gray-fg;
}
.fp_col_count {
font-size: 11px;
font-weight: 700;
color: $fp-muted;
background: $fp-gray-bg;
padding: 2px 8px;
border-radius: 999px;
}
}
.fp_projects_col_body {
display: flex;
flex-direction: column;
gap: 8px;
}
.fp_col_empty {
color: #9ca3af;
font-size: 12px;
text-align: center;
padding: 22px 8px;
background: $fp-card-bg;
border: 1px dashed $fp-border;
border-radius: $fp-radius;
}
}
.fp_dot {
width: 9px;
height: 9px;
border-radius: 999px;
display: inline-block;
&.fp_dot_green { background: #22c55e; }
&.fp_dot_amber { background: #f59e0b; }
&.fp_dot_gray { background: #9ca3af; }
}
// Card
.fp_project_card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 13px 11px;
background: $fp-card-bg;
border: 1px solid $fp-border;
border-radius: $fp-radius;
text-decoration: none;
color: $fp-text;
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
&:hover {
border-color: #93c5fd;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.10);
transform: translateY(-1px);
text-decoration: none;
color: $fp-text;
}
.fp_card_top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
.fp_card_title {
font-weight: 600;
font-size: 14px;
line-height: 1.25;
color: $fp-text;
word-break: break-word;
}
.fp_card_pct {
font-weight: 700;
font-size: 12px;
white-space: nowrap;
&.fp_pct_high { color: #15803d; }
&.fp_pct_mid { color: #1d4ed8; }
&.fp_pct_low { color: #b45309; }
&.fp_card_pct_muted { color: #9ca3af; font-weight: 500; }
}
}
.fp_card_sub {
font-size: 11px;
color: $fp-muted;
min-height: 1em;
}
.fp_card_bar {
height: 5px;
background: $fp-track;
border-radius: 999px;
overflow: hidden;
> span {
display: block;
height: 100%;
background: $fp-bar-mid;
transition: width 200ms ease;
}
&.fp_bar_high > span { background: $fp-bar-high; }
&.fp_bar_mid > span { background: $fp-bar-mid; }
&.fp_bar_low > span { background: $fp-bar-low; }
}
.fp_card_stats {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
.fp_chip {
display: inline-flex;
align-items: center;
font-size: 10.5px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
line-height: 1.4;
white-space: nowrap;
&.fp_chip_blue { background: $fp-blue-bg; color: $fp-blue-fg; }
&.fp_chip_green { background: $fp-green-bg; color: $fp-green-fg; }
&.fp_chip_amber { background: $fp-amber-bg; color: $fp-amber-fg; }
&.fp_chip_cyan { background: $fp-cyan-bg; color: $fp-cyan-fg; }
&.fp_chip_gray { background: $fp-gray-bg; color: $fp-gray-fg; }
}
}
.fp_card_footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 2px;
.fp_avatars {
display: inline-flex;
.fp_avatar {
position: relative;
width: 22px;
height: 22px;
border-radius: 999px;
overflow: hidden;
background: #e0e7ff;
color: #3730a3;
font-size: 9.5px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: -6px;
border: 2px solid $fp-card-bg;
&:first-child { margin-left: 0; }
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.fp_avatar_text {
// hidden when image loads; shown when image fails (handled in JS via classList)
display: none;
}
&.fp_avatar_initials .fp_avatar_text { display: inline; }
&.fp_avatar_initials img { display: none; }
&.fp_avatar_more {
background: $fp-gray-bg;
color: $fp-gray-fg;
}
}
}
}
}
// Empty page state
.fp_projects_empty {
background: $fp-card-bg;
border: 1px dashed $fp-border;
border-radius: $fp-radius;
}
}

View File

@@ -1,13 +1,194 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Redesigned /my/projects page: status columns + rich cards -->
<template id="portal_my_projects" name="Fusion: My Projects (cards)">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<t t-set="_active_ids" t-value="[p.id for p in projects if (fp_project_cards or {}).get(p.id, {}).get('bucket') == 'active']"/>
<t t-set="_idle_ids" t-value="[p.id for p in projects if (fp_project_cards or {}).get(p.id, {}).get('bucket') == 'idle']"/>
<t t-set="_done_ids" t-value="[p.id for p in projects if (fp_project_cards or {}).get(p.id, {}).get('bucket') == 'done']"/>
<div class="fp_projects_page" t-att-data-default-group="fp_groupby or 'status'">
<!-- Page header / control bar -->
<div class="fp_projects_header d-flex flex-wrap align-items-center gap-2 mb-3">
<h3 class="mb-0 me-2">
<i class="fa fa-folder-open-o me-2"/>Projects
<span class="badge bg-secondary ms-2"><t t-esc="len(projects)"/></span>
</h3>
<div class="ms-auto d-flex flex-wrap align-items-center gap-2">
<div class="input-group input-group-sm fp_projects_search_wrap">
<span class="input-group-text bg-white"><i class="fa fa-search"/></span>
<input type="search"
class="form-control form-control-sm fp_projects_search"
placeholder="Search projects..."/>
</div>
<div class="btn-group btn-group-sm fp_projects_group_picker" role="group" aria-label="Group by">
<button type="button" class="btn btn-outline-secondary active" data-group="status">By Status</button>
<button type="button" class="btn btn-outline-secondary" data-group="customer">By Customer</button>
<button type="button" class="btn btn-outline-secondary" data-group="none">Flat</button>
</div>
<div class="dropdown fp_projects_sort_picker">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Sort: <span class="fp_sort_label">Name</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item active" href="#" data-sort="name">Name</a></li>
<li><a class="dropdown-item" href="#" data-sort="pct">% Complete</a></li>
<li><a class="dropdown-item" href="#" data-sort="tasks">Task Count</a></li>
<li><a class="dropdown-item" href="#" data-sort="activity">Last Activity</a></li>
</ul>
</div>
</div>
</div>
<!-- Empty: no projects at all -->
<t t-if="not projects">
<div class="fp_projects_empty text-center py-5">
<div class="fp_empty_glyph mb-3"><i class="fa fa-folder-open-o fa-3x text-muted"/></div>
<h5 class="text-muted mb-1">You don't have any projects yet</h5>
<p class="text-muted small mb-0">Once a project is shared with you, it will appear here.</p>
</div>
</t>
<!-- Columns + cards -->
<t t-if="projects">
<div class="fp_projects_cols">
<t t-call="fusion_project_portal.fp_project_column">
<t t-set="bucket_key" t-value="'active'"/>
<t t-set="bucket_label" t-value="'Active'"/>
<t t-set="bucket_dot" t-value="'green'"/>
<t t-set="bucket_empty" t-value="'No active projects.'"/>
<t t-set="bucket_ids" t-value="_active_ids"/>
</t>
<t t-call="fusion_project_portal.fp_project_column">
<t t-set="bucket_key" t-value="'idle'"/>
<t t-set="bucket_label" t-value="'Idle'"/>
<t t-set="bucket_dot" t-value="'amber'"/>
<t t-set="bucket_empty" t-value="'No idle projects.'"/>
<t t-set="bucket_ids" t-value="_idle_ids"/>
</t>
<t t-call="fusion_project_portal.fp_project_column">
<t t-set="bucket_key" t-value="'done'"/>
<t t-set="bucket_label" t-value="'Done'"/>
<t t-set="bucket_dot" t-value="'gray'"/>
<t t-set="bucket_empty" t-value="'Completed projects will appear here.'"/>
<t t-set="bucket_ids" t-value="_done_ids"/>
</t>
</div>
<div class="fp_projects_no_match d-none text-center py-4 text-muted small">
No projects match your search.
</div>
</t>
<div t-if="pager and pager.get('page_count', 1) > 1" class="mt-3">
<t t-call="portal.pager"/>
</div>
</div>
</t>
</template>
<!-- Single column with its cards -->
<template id="fp_project_column" name="Fusion: project status column">
<div class="fp_projects_col" t-att-data-bucket="bucket_key">
<div class="fp_projects_col_head">
<span class="fp_col_label">
<span t-attf-class="fp_dot fp_dot_{{ bucket_dot }}"/>
<t t-esc="bucket_label"/>
</span>
<span class="fp_col_count" t-esc="len(bucket_ids or [])"/>
</div>
<div class="fp_projects_col_body">
<t t-if="not bucket_ids">
<div class="fp_col_empty"><t t-esc="bucket_empty"/></div>
</t>
<t t-foreach="projects" t-as="project">
<t t-if="project.id in (bucket_ids or [])">
<t t-call="fusion_project_portal.fp_project_card"/>
</t>
</t>
</div>
</div>
</template>
<!-- Rich project card (used inside columns and flat list) -->
<template id="fp_project_card" name="Fusion: project card">
<t t-set="_d" t-value="(fp_project_cards or {}).get(project.id, {})"/>
<t t-set="_pct" t-value="_d.get('pct') or 0"/>
<t t-set="_bucket" t-value="_d.get('bucket') or 'idle'"/>
<t t-set="_total" t-value="_d.get('total_count') or 0"/>
<t t-set="_done" t-value="_d.get('done_count') or 0"/>
<t t-set="_open" t-value="_d.get('open_count') or 0"/>
<t t-set="_alloc" t-value="_d.get('alloc_hours') or 0.0"/>
<t t-set="_spent" t-value="_d.get('spent_hours') or 0.0"/>
<t t-set="_assignees" t-value="_d.get('assignees') or []"/>
<t t-set="_customer" t-value="_d.get('partner_name') or ''"/>
<t t-set="_last" t-value="_d.get('last_activity')"/>
<a t-attf-href="/my/projects/{{ project.id }}"
class="fp_project_card"
t-att-data-bucket="_bucket"
t-att-data-name="project.name"
t-att-data-customer="_customer"
t-att-data-pct="_pct"
t-att-data-tasks="_total"
t-att-data-activity="(_last and _last.isoformat()) or ''">
<div class="fp_card_top">
<span class="fp_card_title"><t t-esc="project.name"/></span>
<span t-if="_pct" t-attf-class="fp_card_pct fp_pct_{{ 'high' if _pct &gt;= 80 else ('mid' if _pct &gt;= 40 else 'low') }}">
<t t-esc="str(int(_pct)) + '%'"/>
</span>
<span t-if="not _pct" class="fp_card_pct fp_card_pct_muted"></span>
</div>
<div class="fp_card_sub">
<t t-if="_customer"><i class="fa fa-user-o me-1 text-muted"/><t t-esc="_customer"/></t>
<t t-else=""><span class="text-muted">No customer set</span></t>
</div>
<div t-attf-class="fp_card_bar fp_bar_{{ 'high' if _pct &gt;= 80 else ('mid' if _pct &gt;= 40 else 'low') }}">
<span t-attf-style="width: {{ _pct }}%;"/>
</div>
<div class="fp_card_stats">
<span t-if="_total" class="fp_chip fp_chip_blue">
<i class="fa fa-list-ul me-1"/><t t-esc="_total"/> task<t t-if="_total != 1">s</t>
</span>
<span t-if="not _total" class="fp_chip fp_chip_gray">No tasks</span>
<span t-if="_done" class="fp_chip fp_chip_green">
<i class="fa fa-check me-1"/><t t-esc="_done"/> done
</span>
<span t-if="_alloc or _spent" class="fp_chip fp_chip_cyan ms-auto">
<i class="fa fa-clock-o me-1"/>
<t t-esc="'%.1fh' % _spent"/>
<t t-if="_alloc"> / <t t-esc="'%.1fh' % _alloc"/></t>
</span>
</div>
<div class="fp_card_footer">
<div class="fp_avatars">
<t t-foreach="_assignees[:4]" t-as="u">
<span class="fp_avatar" t-att-title="u['name']">
<img t-attf-src="/web/image/res.users/{{ u['id'] }}/avatar_128"
t-att-alt="u['name']"
onerror="this.style.display='none';this.parentElement.classList.add('fp_avatar_initials')"/>
<span class="fp_avatar_text"><t t-esc="u['initials']"/></span>
</span>
</t>
<span t-if="len(_assignees) &gt; 4" class="fp_avatar fp_avatar_more">+<t t-esc="len(_assignees) - 4"/></span>
</div>
<span t-if="_last" class="fp_card_activity small text-muted">
<i class="fa fa-history me-1"/><t t-esc="_last.strftime('%b %-d')" t-translation="off"/>
</span>
</div>
</a>
</template>
<!-- Status actions: Request Changes / Approve / Mark Done from the portal task page -->
<template id="portal_my_task_inherit_state_actions"
inherit_id="project.portal_my_task"
name="Fusion: Status actions on portal task page"
priority="55">
<xpath expr="//div[@id='task_chat']" position="before">
<div t-if="fp_can_create_task" class="card mt-4 mb-4 fp_state_card">
<div t-if="fp_can_change_state" class="card mt-4 mb-4 fp_state_card">
<div class="card-header bg-light d-flex align-items-center">
<h5 class="mb-0"><i class="fa fa-flag me-2"/> Status</h5>
<span class="ms-auto badge"
@@ -163,16 +344,35 @@
name="Fusion: Sub-tasks list inside parent task page">
<xpath expr="//div[@id='task_chat']" position="before">
<div class="card mt-4 mb-4 fp_subtasks_card">
<div class="card-header bg-light d-flex align-items-center">
<div class="card-header bg-light d-flex align-items-center flex-wrap gap-2">
<h5 class="mb-0">
<i class="fa fa-sitemap me-2"/>
Sub-tasks
<span class="badge bg-secondary ms-2">
<t t-esc="len(fp_task_descendants or [])"/>
</span>
<span t-if="fp_descendant_total" class="badge bg-success ms-2"
t-attf-title="{{ fp_descendant_done }} of {{ fp_descendant_total }} sub-tasks done">
<i class="fa fa-check-circle me-1"/>
<t t-esc="'%.0f' % (fp_descendant_pct or 0)"/>%
</span>
<span t-if="fp_descendant_alloc_hours or fp_descendant_spent_hours"
class="badge bg-info text-dark ms-2"
title="Spent / Allocated hours across sub-tasks">
<i class="fa fa-clock-o me-1"/>
<t t-esc="'%.1fh' % (fp_descendant_spent_hours or 0)"/>
<span t-if="fp_descendant_alloc_hours">
/ <t t-esc="'%.1fh' % fp_descendant_alloc_hours"/>
</span>
</span>
</h5>
<input t-if="fp_task_descendants"
type="search"
class="form-control form-control-sm fp_subtask_search ms-auto"
style="max-width: 260px;"
placeholder="Search sub-tasks..."/>
<button t-if="fp_can_create_task"
class="btn btn-sm btn-primary ms-auto"
class="btn btn-sm btn-primary"
type="button"
data-bs-toggle="collapse"
t-attf-data-bs-target="#fp_form_root_{{ task.id }}"
@@ -196,7 +396,7 @@
<span t-attf-style="display:inline-block;width:{{ _d * 1.4 }}rem;"/>
<i t-if="_d" class="fa fa-level-up fa-rotate-90 me-2 text-muted small"/>
<a t-attf-href="/my/projects/{{ task.project_id.id }}/task/{{ sub.id }}"
class="flex-grow-1">
t-attf-class="flex-grow-1 #{'text-decoration-line-through text-muted' if sub.state in (fp_done_states or ()) else ''}">
<span t-esc="sub.name"/>
</a>
<button t-if="fp_can_create_task"
@@ -208,7 +408,23 @@
title="Add sub-task here">
<i class="fa fa-plus"/>
</button>
<span t-if="sub.stage_id" class="badge bg-light text-dark border ms-2">
<t t-set="_fp_alloc" t-value="(fp_alloc_by_id or {}).get(sub.id, 0.0)"/>
<t t-set="_fp_spent" t-value="(fp_spent_by_id or {}).get(sub.id, 0.0)"/>
<span t-if="_fp_alloc or _fp_spent" class="badge bg-info text-dark ms-2 small"
title="Spent / Allocated">
<i class="fa fa-clock-o me-1"/>
<t t-esc="'%.1fh' % (_fp_spent or 0)"/>
<t t-if="_fp_alloc"> / <t t-esc="'%.1fh' % _fp_alloc"/></t>
</span>
<span t-if="sub.state in (fp_done_states or ())"
class="badge bg-success ms-2">
<i class="fa fa-check me-1"/> Done
</span>
<span t-elif="sub.state == '02_changes_requested'"
class="badge bg-warning text-dark ms-2">
<i class="fa fa-exclamation-circle me-1"/> Changes
</span>
<span t-elif="sub.stage_id" class="badge bg-light text-dark border ms-2">
<t t-esc="sub.stage_id.name"/>
</span>
</li>

View File

@@ -1,16 +1,16 @@
# -*- coding: utf-8 -*-
{
"name": "Fusion Whitelabels",
"version": "19.0.1.3.0",
"version": "19.0.1.4.3",
"category": "Website",
"summary": "Remove Odoo frontend promotional branding for portal and website pages.",
"summary": "Replace Odoo frontend promotional branding with Nexa Systems whitelabeling.",
"description": """
Fusion Whitelabels
==================
Persistent Odoo 19 whitelabel customizations:
- Removes "Connect with your software" portal promotions.
- Removes global "Powered by Odoo" website/footer promotions.
- Replaces global "Powered by Odoo" website/footer promotions with Nexa Systems credit.
- Removes login-page "Powered by Odoo" footer link.
""",
"author": "Fusion",

View File

@@ -1,3 +1,91 @@
.o-mail-Thread-jumpPresent {
display: none !important;
}
.o_fusion_nexa_brand_promotion {
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: 0.35rem;
width: 100%;
color: inherit;
text-align: right;
opacity: 0.95;
animation: o-fusion-nexa-credit-in 700ms ease-out both;
}
.o_fusion_nexa_brand_label {
white-space: nowrap;
}
.o_fusion_nexa_brand_link {
position: relative;
display: inline-block;
color: #1f6feb;
font-weight: 700;
text-decoration: none !important;
white-space: nowrap;
transition: color 180ms ease, text-shadow 180ms ease;
}
.o_fusion_nexa_brand_link::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -0.16em;
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, #16a085, #1f6feb, #16a085);
background-size: 200% 100%;
transform: scaleX(0.36);
transform-origin: left center;
opacity: 0.72;
transition: transform 220ms ease, opacity 220ms ease;
animation: o-fusion-nexa-underline-flow 2.8s linear infinite;
}
.o_fusion_nexa_brand_link:hover,
.o_fusion_nexa_brand_link:focus {
color: #1f6feb;
text-decoration: none !important;
text-shadow: 0 0 12px rgba(31, 111, 235, 0.18);
}
.o_fusion_nexa_brand_link:hover::after,
.o_fusion_nexa_brand_link:focus::after {
transform: scaleX(1);
opacity: 1;
}
@keyframes o-fusion-nexa-credit-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 0.95;
transform: translateY(0);
}
}
@keyframes o-fusion-nexa-underline-flow {
from {
background-position: 0 0;
}
to {
background-position: 200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.o_fusion_nexa_brand_promotion,
.o_fusion_nexa_brand_link::after {
animation: none;
}
.o_fusion_nexa_brand_link,
.o_fusion_nexa_brand_link::after {
transition: none;
}
}

View File

@@ -18,9 +18,23 @@
<xpath expr="//div[hasclass('d-none','d-lg-block','mt-5','small','text-center','text-muted')]" position="replace"/>
</template>
<template id="fusion_whitelabels_hide_brand_promotion" inherit_id="web.brand_promotion" priority="999">
<xpath expr="//div[hasclass('o_brand_promotion')]" position="attributes">
<attribute name="style">display:none !important;</attribute>
<template id="fusion_whitelabels_nexa_brand_promotion" inherit_id="web.brand_promotion" priority="999">
<xpath expr="//div[hasclass('o_brand_promotion')]" position="replace">
<div class="o_brand_promotion d-none"/>
</xpath>
</template>
<template id="fusion_whitelabels_footer_right_credit" inherit_id="web.frontend_layout" priority="999">
<xpath expr="//footer[@id='bottom']//div[hasclass('o_footer_copyright')]//t[@t-call='web.brand_promotion']/.." position="replace">
<div class="text-center col-md small mt-auto mb-0 text-md-end o_fusion_nexa_footer_column">
<div class="o_fusion_nexa_brand_promotion">
<span class="o_fusion_nexa_brand_label">Designed by</span>
<a class="o_fusion_nexa_brand_link"
href="https://nexasystems.ca"
target="_blank"
rel="noopener noreferrer">Nexa Systems</a>
</div>
</div>
</xpath>
</template>