Files
Odoo-Modules/fusion_clover/CLAUDE.md
gsinghpal a2fe1fcbcc changes
2026-04-29 03:35:33 -04:00

627 lines
31 KiB
Markdown

# 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