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

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)

  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:
    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:

# 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

  1. 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).
  2. 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.
  3. 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).
  4. 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

  1. 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.
  2. 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).
  3. Tokenize requires real brand valueVISA, MC, AMEX, DISCOVER, DINERS, JCB. Don't pass CARD — Clover rejects. See _clover_detect_brand_from_pan() in payment_provider.py.
  4. /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().
  5. Past exp_year accepted in sandbox (e.g. 1970) — production will reject. Don't rely on the tokenize endpoint to validate this.
  6. 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

  1. Webhook events: PAYMENTS + APP are sufficient for our use case. Skip Customers / Inventory / Merchants / Cash / Employees — Odoo is source of truth for those.
  2. 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.
  3. 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

  1. 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".
  2. 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.
  3. 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)

  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_idE2DYXYRBT52K1 (Westin's real merchant)
    • stateenabled (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 teststests/ 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

# 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