# 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', '', 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 ``` . payload_json = { "redirect_to": "https://erp..ca/payment/clover/oauth/callback", "nonce": "<32 hex chars>", "iat": , "customer": "" } ``` 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.` (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": ""}` 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": {"": [{"objectId": "P:/", "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": ""}` 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 `` / `` / `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 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 = "" $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