627 lines
31 KiB
Markdown
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
|