31 KiB
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)
CloverController— 24 edges (controllers/main.py)PaymentTransaction— 19 edgesPaymentProvider— 17 edgesCloverPaymentWizard— 15 edgesCloverRefundWizard— 11 edgesformat_clover_amount()(utils) — 10 edgesAccountMove— 9 edgesCloverTerminal— 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)
- Add hostname to
ALLOWED_REDIRECT_HOSTSAND merchant toMERCHANT_ROUTING_JSON+MERCHANT_ODOO_BASE_JSONinwrangler.jsonc, thencd K:/Github/nexa-oauth-dispatcher && npm run deploy. - On the customer's Odoo, set:
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; - 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)
- App ID (Nexa's
- 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:
wrangler tail nexa-clover-oauth-dispatcher— Worker logs it prominently to the Cloudflare console with a banner.docker logs odoo-dev-app | grep -i 'VERIFICATION CODE'— the Worker also forwards the verification POST to all routed Odoos so thecontrollers/main.py::clover_webhookhandler can log it too (handler atWARNINGlevel 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:
clover_oauth_access_token(preferred — refreshable, app-scoped, works for Platform API + REST Pay Display + Ecommerce)clover_rest_api_token(legacy single-merchant fallback from Clover Dashboard > Setup > API Tokens)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:
# 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
- OAuth v2 authorize lives on
sandbox.dev.clover.com, NOTapisandbox.dev.clover.com. The latter is API-only with no login UI. Wrong host → blank login page that rejects every password. - Path is
/oauth/v2/authorize— Clover deprecated/oauth/authorizein October 2023. Old path still resolves but generates v1-only codes that fail at v2 token endpoint withFailed to validate authentication code401. - Token endpoint is on
apisandbox.dev.clover.com(not the same host as authorize) — Clover splits authorize (UI) from token (API). - Refresh tokens are single-use — see "OAuth token lifecycle" above.
- Sandbox access_token lifetime is ~30 minutes — production is typically longer. Auto-refresh is essential for any long-running process.
- 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
- 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). - 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. - 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).
- 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
- Tokenization endpoint header is
apikey(lowercase), NOTapiAccessKey. The PAKMS endpoint returns a field calledapiAccessKeybut the tokenize endpoint requires a header calledapikey. Wrong header → 401. - PAKMS endpoint is on the Ecommerce host (
scl-sandbox.dev.clover.com/pakms/apikey), NOT the Platform host (apisandbox.dev.clover.com/pakms/apikeyreturns 404). - Tokenize requires real brand value —
VISA,MC,AMEX,DISCOVER,DINERS,JCB. Don't passCARD— Clover rejects. See_clover_detect_brand_from_pan()inpayment_provider.py. /v1/refundsaccepts ONLY{"charge": "..."}— addingamountorreasontriggersInvalid JSON format400. It is full-refund only. For partials use/v1/payments/{paymentId}/refundswith{"amount": cents}or{"fullRefund": true}. Seeutils.build_payment_refund_payload().- Past
exp_yearaccepted in sandbox (e.g.1970) — production will reject. Don't rely on the tokenize endpoint to validate this. - 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
- Webhook events: PAYMENTS + APP are sufficient for our use case. Skip Customers / Inventory / Merchants / Cash / Employees — Odoo is source of truth for those.
- 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 atcontrollers/main.py::_dispatch_clover_webhookaccepts both shapes. - 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
X-POS-Idheader MUST be the Remote App ID (RAID) in production — not a free-form string. RAID is generated when App Type is set toWeb→ "Is this an integration of an existing POS = Yes".- Cloud Pay Display app must be installed AND running on the
Clover device before any
/connect/v1/paymentscall works. The merchant has to manually start it after install. - 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.
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)
# 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)
- 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.
- In Cloudflare Worker
wrangler.jsonc: updateCLOVER_APP_IDto the production App ID. TheMERCHANT_ROUTING_JSONandMERCHANT_ODOO_BASE_JSONshould map Westin's REAL merchant ID (E2DYXYRBT52K1) tohttps://erp.westinhealthcare.ca/payment/clover/webhook. - In Cloudflare Worker secret: rotate
DISPATCHER_SECRETfor production launch (npx wrangler secret put DISPATCHER_SECRET). Also update Westin'sir.config_parameterfusion_clover.dispatcher_secretto match. - In Westin Odoo (
payment_providerrow 36):clover_app_id→ production App IDclover_app_secret→ production App Secretclover_remote_app_id→ production RAIDclover_merchant_id→E2DYXYRBT52K1(Westin's real merchant)state→enabled(NOTtest)- Clear all
clover_oauth_*fields (force re-OAuth)
- On Westin's Clover account: install the Nexa app from the Clover App Market (production now-public version). Authorize.
- In Westin Odoo: click Connect to Clover — runs production OAuth, stores production access_token + refresh_token.
- Click Test Connection: should return "Test Connection successful. Merchant: WESTIN HEALTHCARE".
- Click Sync Terminals: pulls Westin's real Clover terminals.
- Set Default Terminal under Terminal Settings.
- Configure surcharge rates in Settings → Fusion Clover (match fusion_poynt rates so UX is consistent regardless of which processor a clerk picks).
- Set webhook URL on Clover dashboard: same dispatcher URL
https://oauth.nexasystems.ca/clover/webhook. Run verification again (codes appear inwrangler tailand Odoo docker logs). - Smoke test: create a small test invoice, click Collect Clover Payment, complete with a real card under $5, refund it. Verify receipt email + PDF.
- Watch logs for the first few real transactions:
wrangler tail nexa-clover-oauth-dispatcheranddocker 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 deprecatedtype="json") — see all routes incontrollers/main.pyInteractionpatch viapatch(PaymentForm.prototype, ...)instead of IIFE / DOMContentLoadedmodels.Constraint('UNIQUE(serial_number, provider_id)', ...)onclover.terminalinstead of legacy_sql_constraintsres.groupsuseprivilege_id(nocategory_id, nousersfield)- Settings view uses MANDATORY
<block>/<setting id=…>/col-lg-5 o_light_labellayout - Currency:
Monetaryfields withcurrency_field=not bare floats
Outstanding TODOs
- Fix
datetime.utcfromtimestampdeprecation warning (Python 3.12 removes it). Usedatetime.fromtimestamp(ts, tz=datetime.UTC)in_clover_exchange_oauth_codeand_clover_refresh_oauth_token. - Wire partial refund support through the wizard UI — backend
helper
build_payment_refund_payloadexists but isn't called from the wizard yet. The wizard currently always does full refunds. - Add automated tests —
tests/folder is empty. Battle test script lives atC:\Users\gur_p\AppData\Local\Temp\battle_test.pyandhammer_odoo.py— should be cleaned up and committed as actualtests/test_*.pyfiles. - 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.
- Static module icon at
static/description/icon.png(currently shows blank in module browser). - Webhook event handlers for firehose format —
_dispatch_clover_webhookcurrently logsP:-prefixed payment events but doesn't fetch the payment object from Platform API to update the matchingpayment.transaction. Wire this when actual reconciliation gaps surface in production.
Workflow / commands
# 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