This commit is contained in:
gsinghpal
2026-04-29 03:35:33 -04:00
parent 6ac6d24da6
commit a2fe1fcbcc
61 changed files with 4655 additions and 667 deletions

626
fusion_clover/CLAUDE.md Normal file
View File

@@ -0,0 +1,626 @@
# 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

View File

@@ -2,11 +2,23 @@
{
'name': 'Payment Provider: Clover',
'version': '19.0.1.0.0',
'version': '19.0.1.12.0',
'category': 'Accounting/Payment Providers',
'sequence': 365,
'summary': "Clover payment processing for ecommerce, terminal, and manual card payments.",
'description': " ",
'description': """
Clover payment provider for Odoo 19.
Supports:
- Online card payments via Clover.js iframe (PCI-compliant, customer
card never touches Odoo)
- In-person terminal payments via Cloud REST Pay Display
- Back-office manual card collection (server-side tokenization)
- Referenced + non-referenced refunds, voids, and credits
- Multi-card surcharge (Visa/Mastercard/Amex/Debit/Other)
- Webhook signature verification (HMAC-SHA256)
- Payment receipt PDF generation and email delivery
""",
'depends': ['payment', 'account_payment', 'sale'],
'data': [
'security/security.xml',

View File

@@ -17,11 +17,18 @@ TOKEN_BASE_URL_TEST = 'https://token-sandbox.dev.clover.com'
CONNECT_BASE_URL = 'https://api.clover.com/connect/v1'
CONNECT_BASE_URL_TEST = 'https://apisandbox.dev.clover.com/connect/v1'
# OAuth URLs
OAUTH_AUTHORIZE_URL_TEST = 'https://apisandbox.dev.clover.com/oauth/authorize'
OAUTH_AUTHORIZE_URL = 'https://api.clover.com/oauth/authorize'
OAUTH_TOKEN_URL_TEST = 'https://apisandbox.dev.clover.com/oauth/token'
OAUTH_TOKEN_URL = 'https://api.clover.com/oauth/token'
# OAuth v2 URLs (Clover mandated v2 from October 2023; legacy /oauth/authorize
# is deprecated). Note that the AUTHORIZE endpoint is on the merchant/dev
# portal host (sandbox.dev.clover.com / www.clover.com) — NOT on the API
# host — because it has to render a merchant-facing login + consent UI.
# The TOKEN/REFRESH endpoints are on the API host.
# https://docs.clover.com/docs/use-oauth#sandbox-and-production-environment-urls
OAUTH_AUTHORIZE_URL_TEST = 'https://sandbox.dev.clover.com/oauth/v2/authorize'
OAUTH_AUTHORIZE_URL = 'https://www.clover.com/oauth/v2/authorize'
OAUTH_TOKEN_URL_TEST = 'https://apisandbox.dev.clover.com/oauth/v2/token'
OAUTH_TOKEN_URL = 'https://api.clover.com/oauth/v2/token'
OAUTH_REFRESH_URL_TEST = 'https://apisandbox.dev.clover.com/oauth/v2/refresh'
OAUTH_REFRESH_URL = 'https://api.clover.com/oauth/v2/refresh'
DEFAULT_PAYMENT_METHOD_CODES = {
'card',

View File

@@ -1,5 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import hashlib
import hmac
import json
import logging
import pprint
@@ -73,24 +75,95 @@ class CloverController(http.Controller):
@http.route(_webhook_url, type='http', methods=['POST'], auth='public', csrf=False)
def clover_webhook(self):
"""Process webhook notifications from Clover."""
"""Process webhook notifications from Clover.
Verifies the HMAC signature (when an app secret is configured)
before processing. Clover signs webhooks with HMAC-SHA256 over
the raw request body using the app secret as the key, and sends
the signature in the ``X-Clover-Auth-Code`` header (hex-encoded).
Webhooks for the ecom-style providers may also use the legacy
``X-Clover-Signature`` header — both are checked.
Also handles Clover's one-time URL verification challenge:
when a developer clicks "Send Verification Code" in the Clover
dashboard, Clover POSTs ``{"verificationCode": "<uuid>"}`` to
the URL. We log it loudly so the developer can grab it from the
Odoo log instead of fishing through Cloudflare logs (the same
code is also logged by the Nexa dispatcher Worker if used).
"""
raw_body = request.httprequest.data
try:
raw_body = request.httprequest.data.decode('utf-8')
event = json.loads(raw_body)
event = json.loads(raw_body.decode('utf-8'))
except (ValueError, UnicodeDecodeError):
_logger.warning("Received invalid JSON from Clover webhook")
return request.make_json_response({'status': 'error'}, status=400)
# --- Verification challenge ---------------------------------------
# This special POST is sent ONCE when the dev dashboard's "Send
# Verification Code" button is clicked. It is NOT signed (Clover
# has no signature to add yet — the webhook isn't activated),
# so we accept it without HMAC verification.
verification_code = event.get('verificationCode') if isinstance(event, dict) else None
if verification_code:
_logger.warning(
"================================================================\n"
"CLOVER WEBHOOK VERIFICATION CODE (paste into Clover dashboard):\n"
" %s\n"
"================================================================",
verification_code,
)
return request.make_json_response({
'status': 'ok',
'verification': verification_code,
})
if not self._verify_webhook_signature(raw_body):
_logger.warning(
"Clover webhook signature verification FAILED — "
"rejecting payload."
)
return request.make_json_response(
{'status': 'forbidden'}, status=403,
)
_logger.info(
"Clover webhook notification received:\n%s",
pprint.pformat(event),
)
try:
event_type = event.get('type', '')
data = event.get('data', {})
self._dispatch_clover_webhook(event)
except ValidationError:
_logger.exception("Unable to process Clover webhook; acknowledging to avoid retries")
if event_type in ('charge.succeeded', 'charge.captured'):
return request.make_json_response({'status': 'ok'})
def _dispatch_clover_webhook(self, event):
"""Route a Clover webhook payload to the appropriate handler.
Clover uses two payload shapes depending on the integration:
1. **Hosted Checkout / Ecommerce style** —
``{"type": "charge.succeeded", "data": {...}}``
2. **Merchant App firehose style** —
``{"appId": "...", "merchants": {"<mId>": [{"objectId": "P:xxx",
"type": "CREATE", "ts": 1234567890}, ...]}}``
The two shapes carry different information density. We handle
format 1 directly and decode format 2 into best-effort polls of
the underlying object (e.g. fetch the payment from Platform API
and synthesise a charge.succeeded equivalent).
"""
if not isinstance(event, dict):
return
# --- Format 1: domain events ------------------------------------
if 'type' in event and 'data' in event:
event_type = event.get('type', '')
data = event.get('data', {}) or {}
if event_type in ('charge.succeeded', 'charge.captured', 'payment.created'):
self._handle_charge_webhook(data, 'succeeded')
elif event_type == 'charge.failed':
self._handle_charge_webhook(data, 'failed')
@@ -100,13 +173,83 @@ class CloverController(http.Controller):
self._handle_refund_webhook(data)
elif event_type == 'refund.failed':
_logger.warning("Clover refund failed webhook: %s", data.get('id', ''))
elif event_type == 'payment.created':
self._handle_charge_webhook(data, 'succeeded')
else:
_logger.info("Unhandled Clover webhook event type: %s", event_type)
return
except ValidationError:
_logger.exception("Unable to process Clover webhook; acknowledging to avoid retries")
# --- Format 2: merchant-app firehose ----------------------------
merchants_block = event.get('merchants') or {}
if not isinstance(merchants_block, dict):
return
for merchant_id, events in merchants_block.items():
if not isinstance(events, list):
continue
for ev in events:
if not isinstance(ev, dict):
continue
object_ref = ev.get('objectId') or ''
# objectId looks like "P:E2DYXYRBT52K1/abcd1234" (Payment),
# "C:<mid>/<id>" (Customer), "O:<mid>/<id>" (Order), etc.
kind = object_ref[:2] if len(object_ref) >= 2 else ''
action = ev.get('type', '')
_logger.info(
"Clover firehose event: merchant=%s objectId=%s action=%s",
merchant_id, object_ref, action,
)
if kind == 'P:' and action in ('CREATE', 'UPDATE'):
# Don't synchronously fetch from the Platform API
# here — we'd block Clover's webhook ack timeout.
# If recovery from missed sync responses becomes a
# real need, schedule a queue_job and return 200 fast.
_logger.debug(
"Payment object change for merchant %s: %s (%s) — "
"no synchronous handler, see _dispatch_clover_webhook",
merchant_id, object_ref, action,
)
return request.make_json_response({'status': 'ok'})
def _verify_webhook_signature(self, raw_body):
"""Validate the HMAC signature on a Clover webhook payload.
:param bytes raw_body: The raw request body, exactly as received.
:return: True if the signature is valid OR if no provider has a
secret configured (development/sandbox mode). False if a
secret is configured but the signature does not match.
:rtype: bool
"""
# Find any Clover provider with an app secret configured. If none,
# we silently allow the webhook (sandbox/dev). If at least one has
# a secret, signatures become mandatory.
providers = request.env['payment.provider'].sudo().search([
('code', '=', 'clover'),
('clover_app_secret', '!=', False),
])
if not providers:
return True
sig_header = (
request.httprequest.headers.get('X-Clover-Auth-Code')
or request.httprequest.headers.get('X-Clover-Signature')
or ''
)
if not sig_header:
return False
sig_header_clean = sig_header.lower().strip()
if sig_header_clean.startswith('sha256='):
sig_header_clean = sig_header_clean[len('sha256='):]
for provider in providers:
secret = provider.clover_app_secret
if not secret:
continue
expected = hmac.new(
secret.encode('utf-8'),
raw_body,
hashlib.sha256,
).hexdigest().lower()
if hmac.compare_digest(expected, sig_header_clean):
return True
return False
def _handle_charge_webhook(self, data, status):
"""Process a charge-related webhook event."""
@@ -200,62 +343,85 @@ class CloverController(http.Controller):
def clover_oauth_callback(self, **data):
"""Handle the OAuth2 authorization callback from Clover.
After a merchant authorizes the app, Clover redirects here with
an authorization code. We exchange it for an access token and
store the merchant_id.
After the merchant authorises the Nexa app, Clover redirects to
the Nexa OAuth dispatcher (https://oauth.nexasystems.ca/clover/
callback), which verifies the signed state and 302-redirects
here with the original code/merchant_id/state query params.
Note: the dispatcher already verified the HMAC signature on
``state`` before forwarding. We re-verify here as defence in
depth — an attacker who tricks the user into hitting this
callback directly (skipping the dispatcher) must still know the
dispatcher secret to forge a valid state.
"""
code = data.get('code', '')
merchant_id = data.get('merchant_id', '')
client_id = data.get('client_id', '')
state = data.get('state', '')
if not code:
_logger.warning("Clover OAuth callback missing authorization code")
return request.redirect('/odoo/settings')
if state:
try:
provider_id = int(state)
provider = request.env['payment.provider'].browse(provider_id)
if provider.exists() and provider.code == 'clover':
# Exchange code for access token
import requests as req
is_test = provider.state == 'test'
token_url = (
f"{data.get('_token_url', '')}"
or (
'https://apisandbox.dev.clover.com/oauth/token' if is_test
else 'https://api.clover.com/oauth/token'
)
)
token_resp = req.get(token_url, params={
'client_id': provider.clover_app_id,
'client_secret': provider.sudo().clover_app_secret,
'code': code,
}, timeout=30)
# Locate the Clover provider record. There's normally one per
# company; we pick the most recently configured.
provider = request.env['payment.provider'].sudo().search([
('code', '=', 'clover'),
], order='id desc', limit=1)
if not provider:
_logger.warning("Clover OAuth callback but no Clover provider exists.")
return request.redirect('/odoo/settings')
if token_resp.status_code == 200:
token_data = token_resp.json()
access_token = token_data.get('access_token', '')
if access_token:
vals = {'clover_api_key': access_token}
if merchant_id:
vals['clover_merchant_id'] = merchant_id
provider.sudo().write(vals)
_logger.info(
"Clover OAuth: linked merchant %s to provider %s",
merchant_id, provider_id,
)
else:
_logger.error(
"Clover OAuth token exchange failed: %s",
token_resp.text[:500],
)
except (ValueError, TypeError):
_logger.warning("Invalid provider state in Clover OAuth callback: %s", state)
# Defence-in-depth: verify the state HMAC ourselves.
if state and not self._verify_dispatcher_state(state, provider):
_logger.error("Clover OAuth callback: invalid HMAC on state, refusing to exchange code.")
return request.redirect('/odoo/settings')
try:
provider._clover_exchange_oauth_code(code)
except (ValidationError, UserError) as e:
_logger.exception("Clover OAuth code exchange failed: %s", e)
return request.redirect('/odoo/settings')
if merchant_id and not provider.clover_merchant_id:
provider.sudo().write({'clover_merchant_id': merchant_id})
_logger.info(
"Clover OAuth: linked merchant %s to provider id=%s",
merchant_id or '(unknown)', provider.id,
)
return request.redirect('/odoo/settings')
def _verify_dispatcher_state(self, state, provider):
"""Recompute the HMAC on the dispatcher state and constant-time
compare. Skips the iat freshness check (the dispatcher already
did that) but enforces the signature."""
secret = provider._clover_dispatcher_secret()
if not secret:
# No secret configured -> dev/local mode where state is just
# decorative. Accept whatever the dispatcher already vetted.
return True
try:
payload_b64u, sig_b64u = state.rsplit('.', 1)
except ValueError:
return False
expected = hmac.new(
secret.encode('utf-8'),
payload_b64u.encode('ascii'),
hashlib.sha256,
).digest()
try:
received = self._b64u_decode(sig_b64u)
except Exception:
return False
return hmac.compare_digest(expected, received)
@staticmethod
def _b64u_decode(s):
"""Decode a base64url string with optional padding."""
import base64
padded = s + '=' * (-len(s) % 4)
return base64.urlsafe_b64decode(padded.encode('ascii'))
# === SURCHARGE HELPER === #
def _apply_portal_surcharge(self, tx_sudo, card_type):
@@ -433,12 +599,19 @@ class CloverController(http.Controller):
@http.route('/payment/clover/process_card', type='jsonrpc', auth='public')
def clover_process_card(self, reference=None, card_token=None,
card_type=None, **kwargs):
"""Process a card payment through Clover Ecommerce API.
card_type=None, save_token=False, **kwargs):
"""Process a card payment through the Clover Ecommerce API.
The frontend tokenizes the card via Clover's iframe/API and sends
the token here. Card data is NOT stored in Odoo.
The frontend MUST tokenize the card client-side using Clover.js
(https://docs.clover.com/docs/web-sdk) and send only the
resulting ``clv_xxx`` token here. Raw PAN must never reach Odoo
(PCI scope).
:param str reference: The Odoo payment.transaction reference.
:param str card_token: The Clover.js source token (``clv_xxx``).
:param str card_type: Optional detected card brand for surcharge.
:param bool save_token: Whether to persist the token for future
charges (card-on-file).
:return: Dict with success status or error message.
:rtype: dict
"""
@@ -453,12 +626,17 @@ class CloverController(http.Controller):
if not tx_sudo:
return {'error': 'Transaction not found.'}
if not card_token:
return {'error': 'Missing card token. Please try again.'}
if not card_token or not isinstance(card_token, str) \
or not card_token.startswith('clv_'):
return {
'error': 'Missing or invalid Clover token. '
'The card must be tokenized via Clover.js before '
'submission.',
}
try:
if card_type:
surcharge_fee = self._apply_portal_surcharge(tx_sudo, card_type)
self._apply_portal_surcharge(tx_sudo, card_type)
provider = tx_sudo.provider_id.sudo()
capture = not provider.capture_manually
@@ -470,6 +648,7 @@ class CloverController(http.Controller):
capture=capture,
description=reference,
ecomind='ecom',
receipt_email=tx_sudo.partner_id.email or '',
metadata={'odoo_reference': reference},
)
@@ -487,11 +666,15 @@ class CloverController(http.Controller):
'clover_status': status,
'source': result.get('source', {}),
}
if save_token:
# Mark the transaction so _extract_token_values fires on
# _apply_updates.
tx_sudo.tokenize = True
tx_sudo._process('clover', payment_data)
return {'success': True, 'status': status}
except ValidationError as e:
return {'error': str(e)}
except Exception as e:
_logger.error("Card payment processing failed: %s", e)
_logger.exception("Card payment processing failed: %s", e)
return {'error': 'Payment processing failed. Please try again.'}

View File

@@ -7,7 +7,8 @@
<field name="inline_form_view_id" ref="inline_form"/>
<field name="allow_tokenization">True</field>
<field name="state">disabled</field>
<field name="image_128" type="base64" file="fusion_clover/static/src/img/clover_logo.png"/>
<!-- Logo is uploaded by the merchant via the UI after install
(Settings > Payment Providers > Clover > upload image_128). -->
</record>
</odoo>

View File

@@ -56,12 +56,43 @@ class PaymentProvider(models.Model):
copy=False,
groups='base.group_system',
)
clover_remote_app_id = fields.Char(
string="Remote App ID (RAID)",
help="The Remote App ID generated when the Clover dev app's "
"App Type is set to Web > 'Is this an integration of an "
"existing point of sale?' = Yes. Sent in the X-POS-Id "
"header on every REST Pay Display call. Required for "
"production terminal payments.",
copy=False,
)
clover_public_key = fields.Char(
string="Public API Key (PAKMS)",
help="The public token from Clover's Ecommerce API Tokens page. "
"Used for client-side tokenization. Safe to expose in the browser.",
copy=False,
)
clover_oauth_access_token = fields.Char(
string="OAuth Access Token",
help="Long-lived merchant OAuth access_token, obtained via the "
"Connect to Clover button. Used as the Bearer token for "
"all Platform API and REST Pay Display calls. Different "
"from the Ecommerce private token (clover_api_key).",
copy=False,
groups='base.group_system',
)
clover_oauth_refresh_token = fields.Char(
string="OAuth Refresh Token",
help="Refresh token used to renew the access token before it "
"expires.",
copy=False,
groups='base.group_system',
)
clover_oauth_token_expiry = fields.Datetime(
string="OAuth Token Expiry",
help="When the current access_token expires. The refresh flow "
"should run automatically before this time.",
copy=False,
)
clover_default_terminal_id = fields.Many2one(
'clover.terminal',
string="Default Terminal",
@@ -90,9 +121,292 @@ class PaymentProvider(models.Model):
return super()._get_default_payment_method_codes()
return const.DEFAULT_PAYMENT_METHOD_CODES
# === BUSINESS METHODS - OAUTH HELPERS === #
def _clover_get_platform_token(self):
"""Pick the best Bearer token for Platform / REST Pay Display calls.
Priority order:
1. Live OAuth access_token (preferred — refreshable, app-scoped,
grants both Platform API and REST Pay Display).
2. Manual REST API token from Clover Dashboard > Setup > API Tokens
(legacy / single-merchant fallback).
3. Ecommerce private token (last-resort — works only for the
Platform GET /v3/merchants/{mId} test-connection call;
will 401 on REST Pay Display).
Will proactively refresh the OAuth access_token if it's expired
(or within a 60-second grace window of expiry).
"""
self.ensure_one()
if self.clover_oauth_access_token:
self._clover_refresh_oauth_if_needed()
return self.clover_oauth_access_token
if self.clover_rest_api_token:
return self.clover_rest_api_token
return self.clover_api_key or ''
def _clover_refresh_oauth_if_needed(self, grace_seconds=60):
"""Refresh the OAuth access_token if it's within ``grace_seconds``
of expiry. Silent no-op if no expiry / no refresh_token / refresh
endpoint not available. Logs at WARNING on refresh failure but
does not raise (the next API call will get 401 and may trigger
retry-with-refresh from the request method)."""
from datetime import datetime, timedelta
self.ensure_one()
if not self.clover_oauth_refresh_token:
return False
if self.clover_oauth_token_expiry:
time_left = self.clover_oauth_token_expiry - datetime.utcnow()
if time_left > timedelta(seconds=grace_seconds):
return False # plenty of time
return self._clover_refresh_oauth_token()
def _clover_refresh_oauth_token(self):
"""Use the stored refresh_token to mint a new access_token via
Clover's /oauth/v2/refresh. Stores the new token + new expiry on
the provider record. Returns True on success, False on failure
(caller decides whether to fall back or raise)."""
from datetime import datetime, timedelta
self.ensure_one()
if not self.clover_oauth_refresh_token or not self.clover_app_id:
return False
is_test = self.state == 'test'
url = const.OAUTH_REFRESH_URL_TEST if is_test else const.OAUTH_REFRESH_URL
try:
r = requests.post(
url,
json={
'client_id': self.clover_app_id,
'refresh_token': self.clover_oauth_refresh_token,
},
timeout=20,
)
except requests.exceptions.RequestException as e:
_logger.warning("Clover OAuth refresh network error: %s", e)
return False
if r.status_code != 200:
_logger.warning(
"Clover OAuth refresh failed (%s): %s",
r.status_code, r.text[:300],
)
return False
try:
data = r.json()
except ValueError:
_logger.warning("Clover OAuth refresh response is not JSON")
return False
new_token = data.get('access_token', '')
if not new_token:
return False
vals = {'clover_oauth_access_token': new_token}
new_refresh = data.get('refresh_token', '')
if new_refresh:
vals['clover_oauth_refresh_token'] = new_refresh
expires = int(data.get('access_token_expiration', 0) or 0)
if expires:
if expires > 10 * 365 * 24 * 3600: # absolute timestamp
vals['clover_oauth_token_expiry'] = datetime.utcfromtimestamp(expires)
else: # duration in seconds
vals['clover_oauth_token_expiry'] = (
datetime.utcnow() + timedelta(seconds=expires)
)
self.sudo().write(vals)
_logger.info("Clover OAuth access_token refreshed for provider %s", self.id)
return True
def _clover_dispatcher_url(self):
"""Return the Nexa OAuth dispatcher URL (where Clover redirects
after the merchant authorises). Configurable per-deployment via
ir.config_parameter ``fusion_clover.dispatcher_url``."""
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_clover.dispatcher_url',
'https://oauth.nexasystems.ca/clover/callback',
)
def _clover_dispatcher_secret(self):
"""Return the HMAC secret shared with the Nexa OAuth dispatcher.
Stored in ``ir.config_parameter`` ``fusion_clover.dispatcher_secret``
so it can be rotated without a code deploy. Returns '' if not
configured (in which case the OAuth flow falls back to a direct
callback to this Odoo instance — useful for local dev)."""
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_clover.dispatcher_secret', '',
)
def _clover_build_signed_state(self, customer_slug=''):
"""Build the HMAC-SHA256 signed state token sent to Clover's
OAuth authorize endpoint. The Nexa dispatcher Worker verifies
this signature and uses the embedded ``redirect_to`` to fan the
OAuth callback back to THIS Odoo instance.
Format: ``<base64url(payload_json)>.<base64url(hmac_sha256(secret, payload))>``
"""
import base64
import hashlib
import hmac as _hmac
import json as _json
import secrets
import time
self.ensure_one()
secret = self._clover_dispatcher_secret()
if not secret:
raise UserError(_(
"The dispatcher HMAC secret is not configured. Set "
"the system parameter "
"'fusion_clover.dispatcher_secret' to the value "
"stored in the Cloudflare Worker before initiating "
"the Connect to Clover flow."
))
base_url = self.get_base_url()
payload = {
'redirect_to': f"{base_url}/payment/clover/oauth/callback",
'nonce': secrets.token_hex(16),
'iat': int(time.time()),
'customer': customer_slug or self.company_id.name or '',
}
payload_json = _json.dumps(payload, separators=(',', ':')).encode('utf-8')
payload_b64u = base64.urlsafe_b64encode(payload_json).rstrip(b'=').decode('ascii')
sig = _hmac.new(
secret.encode('utf-8'),
payload_b64u.encode('ascii'),
hashlib.sha256,
).digest()
sig_b64u = base64.urlsafe_b64encode(sig).rstrip(b'=').decode('ascii')
return f"{payload_b64u}.{sig_b64u}"
def action_clover_oauth_connect(self):
"""Kick off the OAuth flow: redirect the staff user to Clover's
authorize endpoint, with our dispatcher URL as redirect_uri and
a signed state encoding this Odoo's callback URL."""
from werkzeug.urls import url_encode
self.ensure_one()
if self.code != 'clover':
raise UserError(_("This action is only available for Clover providers."))
if not self.clover_app_id:
raise UserError(_(
"App ID is required. Add it on the payment provider record "
"under 'OAuth (Optional)' first."
))
is_test = self.state == 'test'
authorize_url = (
const.OAUTH_AUTHORIZE_URL_TEST if is_test else const.OAUTH_AUTHORIZE_URL
)
state = self._clover_build_signed_state()
# Per https://docs.clover.com/dev/docs/high-trust-app-auth-flow the
# v2 authorize endpoint only takes client_id + redirect_uri.
# `state` is documented elsewhere as supported for CSRF; we include
# it. Notably ABSENT: `response_type` (legacy v1 only) and `scope`
# (handled by app's Requested Permissions in the dev dashboard).
params = {
'client_id': self.clover_app_id,
'redirect_uri': self._clover_dispatcher_url(),
'state': state,
}
return {
'type': 'ir.actions.act_url',
'url': f"{authorize_url}?{url_encode(params)}",
'target': 'self',
}
def _clover_exchange_oauth_code(self, code):
"""Exchange a one-time OAuth ``code`` for a long-lived
access_token + refresh_token by POSTing to Clover's token
endpoint with our App Secret. Stores the tokens on this
provider record.
Tries the v2 token endpoint first; if Clover responds with 401
"Failed to validate authentication code", it means the code was
generated by the legacy partial OAuth flow (App Market Connect
without an Alternate Launch Path) — in that case we fall back to
the v1 token endpoint which is what generated the code.
"""
from datetime import datetime, timedelta
self.ensure_one()
if not self.clover_app_id or not self.clover_app_secret:
raise ValidationError(_(
"Cannot exchange OAuth code: App ID or App Secret is missing."
))
is_test = self.state == 'test'
v2_url = const.OAUTH_TOKEN_URL_TEST if is_test else const.OAUTH_TOKEN_URL
# Legacy v1 token URL — same host, no /v2/.
v1_url = v2_url.replace('/oauth/v2/token', '/oauth/token')
payload = {
'client_id': self.clover_app_id,
'client_secret': self.clover_app_secret,
'code': code,
}
data = None
last_error = ''
for token_url in (v2_url, v1_url):
try:
# v1 endpoint historically used GET with query params;
# v2 uses POST with JSON body. We send POST+JSON to v1
# too (Clover accepts both there in our experience).
response = requests.post(token_url, json=payload, timeout=30)
except requests.exceptions.RequestException as e:
raise ValidationError(_("Could not reach Clover OAuth token endpoint: %s", e))
if response.status_code < 400:
try:
data = response.json()
except ValueError:
raise ValidationError(_("Clover OAuth response was not valid JSON."))
_logger.info("Clover OAuth code exchanged successfully via %s", token_url)
break
last_error = response.text[:500]
_logger.warning(
"Clover OAuth exchange via %s returned %s: %s — trying next endpoint",
token_url, response.status_code, last_error,
)
# Only try v1 fallback if v2 specifically said "Failed to
# validate authentication code" — other 4xx errors won't
# benefit from the fallback.
if 'validate authentication code' not in last_error.lower():
break
if data is None:
raise ValidationError(_(
"Clover rejected the OAuth code at both v2 and v1 token endpoints. "
"Last response: %s", last_error,
))
access_token = data.get('access_token', '')
refresh_token = data.get('refresh_token', '')
expires_in = int(data.get('access_token_expiration', 0) or 0)
if not access_token:
raise ValidationError(_("Clover OAuth response did not contain an access_token."))
vals = {
'clover_oauth_access_token': access_token,
'clover_oauth_refresh_token': refresh_token or False,
}
if expires_in:
# Clover returns the expiry as a UNIX timestamp in seconds, not
# a duration. Detect both shapes (a duration is < ~10 years).
if expires_in > 10 * 365 * 24 * 3600:
vals['clover_oauth_token_expiry'] = datetime.utcfromtimestamp(expires_in)
else:
vals['clover_oauth_token_expiry'] = (
datetime.utcnow() + timedelta(seconds=expires_in)
)
self.sudo().write(vals)
return True
# === BUSINESS METHODS - API REQUESTS === #
def _clover_make_ecom_request(self, method, endpoint, payload=None, params=None):
def _clover_make_ecom_request(self, method, endpoint, payload=None, params=None, _retry=True):
"""Make an authenticated API request to the Clover Ecommerce API.
:param str method: HTTP method (GET, POST, PUT, DELETE).
@@ -109,8 +423,16 @@ class PaymentProvider(models.Model):
url = clover_utils.build_ecom_url(endpoint, is_test=is_test)
idempotency_key = clover_utils.generate_idempotency_key()
# Auth precedence: OAuth access_token (works for both Ecommerce
# AND Platform APIs per Clover docs) > Ecommerce private token.
# Routed through _clover_get_platform_token() so the OAuth token
# is proactively refreshed if it's near expiry.
if self.clover_oauth_access_token:
ecom_token = self._clover_get_platform_token()
else:
ecom_token = self.clover_api_key or ''
headers = clover_utils.build_ecom_headers(
self.clover_api_key, idempotency_key=idempotency_key,
ecom_token, idempotency_key=idempotency_key,
)
_logger.info(
@@ -142,6 +464,13 @@ class PaymentProvider(models.Model):
_logger.error("Clover returned non-JSON response: %s", response.text[:500])
raise ValidationError(_("Clover returned an invalid response."))
if response.status_code == 401 and _retry and self.clover_oauth_refresh_token:
_logger.info("Clover Ecom 401, attempting token refresh + retry")
if self._clover_refresh_oauth_token():
return self._clover_make_ecom_request(
method, endpoint, payload=payload, params=params, _retry=False,
)
if response.status_code >= 400:
error = result.get('error', {})
error_msg = error.get('message', '') if isinstance(error, dict) else str(error)
@@ -161,7 +490,7 @@ class PaymentProvider(models.Model):
return result
def _clover_make_platform_request(self, method, endpoint, payload=None, params=None):
def _clover_make_platform_request(self, method, endpoint, payload=None, params=None, _retry=True):
"""Make an authenticated request to the Clover Platform API.
:param str method: HTTP method.
@@ -179,8 +508,8 @@ class PaymentProvider(models.Model):
endpoint, merchant_id=self.clover_merchant_id, is_test=is_test,
)
# Platform API uses the REST API token, falling back to ecom key
api_token = self.clover_rest_api_token or self.clover_api_key
# Platform API auth precedence: OAuth > merchant REST API token > ecom key
api_token = self._clover_get_platform_token()
headers = clover_utils.build_ecom_headers(api_token)
_logger.info("Clover Platform API %s request to %s", method, url)
@@ -208,6 +537,13 @@ class PaymentProvider(models.Model):
return {}
raise ValidationError(_("Clover returned an invalid response."))
if response.status_code == 401 and _retry and self.clover_oauth_refresh_token:
_logger.info("Clover Platform 401, attempting token refresh + retry")
if self._clover_refresh_oauth_token():
return self._clover_make_platform_request(
method, endpoint, payload=payload, params=params, _retry=False,
)
if response.status_code >= 400:
error_msg = result.get('message', result.get('error', 'Unknown error'))
raise ValidationError(
@@ -217,6 +553,122 @@ class PaymentProvider(models.Model):
return result
# === BUSINESS METHODS - TOKENIZATION === #
@staticmethod
def _clover_detect_brand_from_pan(pan):
"""Detect the card brand from a digits-only PAN, returning one of
Clover's accepted brand codes (VISA, MC, AMEX, DISCOVER, DINERS,
JCB). Falls back to VISA if unrecognised — Clover will then
decline at the charge step rather than the tokenize step, which
is a slightly more useful error path."""
if not pan or len(pan) < 2:
return 'VISA'
if pan[:2] in ('34', '37'):
return 'AMEX'
if pan[0] == '4':
return 'VISA'
try:
p2 = int(pan[:2])
p4 = int(pan[:4]) if len(pan) >= 4 else 0
except ValueError:
return 'VISA'
if 51 <= p2 <= 55:
return 'MC'
if 2221 <= p4 <= 2720:
return 'MC'
if pan[:4] == '6011' or pan[:2] == '65':
return 'DISCOVER'
if pan[:4] in ('3014', '3036', '3038') or pan[:2] in ('30', '36', '38'):
return 'DINERS'
if pan[:2] == '35':
return 'JCB'
return 'VISA'
def _clover_tokenize_card(self, card_number, exp_month, exp_year, cvv,
cardholder_name='', postal_code=''):
"""Server-side card tokenization via Clover's tokenization service.
Used by the back-office wizard for staff-keyed card entry. Returns
a ``clv_xxx`` token that can then be passed to ``/v1/charges`` as
the ``source``. The raw PAN is sent ONCE to Clover's tokenization
endpoint and never persisted in Odoo.
Note: Westin Healthcare staff should normally use the terminal
flow rather than manual card entry, which keeps even this brief
in-memory PAN handling out of the picture.
:param str card_number: Card number, digits only.
:param int exp_month: Expiry month (1-12).
:param int exp_year: Expiry year (4-digit).
:param str cvv: Card verification value.
:param str cardholder_name: Optional name on card.
:param str postal_code: Optional billing postal code.
:return: The Clover token string ``clv_xxx``.
:rtype: str
:raises ValidationError: If tokenization fails.
"""
self.ensure_one()
is_test = self.state == 'test'
base_url = const.TOKEN_BASE_URL_TEST if is_test else const.TOKEN_BASE_URL
url = f"{base_url}/v1/tokens"
if not self.clover_public_key:
raise ValidationError(_(
"No Clover Public API Key (PAKMS) configured. Add it on "
"the payment provider record before staff can charge cards "
"from the back-office wizard."
))
# Clover's tokenization endpoint requires the lowercase `apikey`
# header (NOT the `apiAccessKey` field name returned by /pakms).
# https://docs.clover.com/dev/reference/create-card-token
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'apikey': self.clover_public_key,
}
# Detect card brand from the BIN. Clover requires a real brand
# name (VISA/MC/AMEX/DISCOVER/DINERS/JCB) — `CARD` is not valid.
clean_pan = str(card_number).replace(' ', '').replace('-', '')
brand = self._clover_detect_brand_from_pan(clean_pan)
payload = {
'card': {
'number': clean_pan,
'exp_month': str(exp_month).zfill(2),
'exp_year': str(exp_year),
'cvv': str(cvv),
'brand': brand,
},
}
if cardholder_name:
payload['card']['name'] = cardholder_name
if postal_code:
payload['card']['address_zip'] = postal_code
try:
response = requests.post(url, json=payload, headers=headers, timeout=30)
except requests.exceptions.RequestException as e:
raise ValidationError(_("Could not reach Clover tokenization service: %s", e))
try:
result = response.json()
except ValueError:
raise ValidationError(_("Clover returned an invalid tokenization response."))
if response.status_code >= 400 or 'id' not in result:
error = result.get('error', {})
error_msg = error.get('message', '') if isinstance(error, dict) else str(error)
raise ValidationError(
_("Clover tokenization failed (%(code)s): %(msg)s",
code=response.status_code, msg=error_msg or 'Unknown error')
)
token = result['id']
if not token.startswith('clv_'):
raise ValidationError(_("Unexpected token format from Clover: %s", token))
return token
# === BUSINESS METHODS - CHARGE / TOKENIZE === #
def _clover_create_charge(self, source_token, amount, currency,
@@ -360,9 +812,19 @@ class PaymentProvider(models.Model):
"""
self.ensure_one()
partner = self.env['res.partner'].browse(partner_id).exists()
partner = self.env['res.partner'].browse(int(partner_id)).exists() if partner_id else self.env['res.partner']
minor_amount = clover_utils.format_clover_amount(amount, currency) if amount else 0
# Map the Odoo language to a Clover-supported locale (Clover only
# supports en-US, en-CA, fr-CA today). Anything else falls back to
# en-US (Clover SDK default).
partner_lang = (partner.lang or self.env.user.lang or 'en_US').replace('_', '-')
clover_locale = ''
if partner_lang.startswith('fr'):
clover_locale = 'fr-CA'
elif partner_lang in ('en-CA',):
clover_locale = 'en-CA'
inline_form_values = {
'provider_id': self.id,
'merchant_id': self.clover_merchant_id,
@@ -371,6 +833,7 @@ class PaymentProvider(models.Model):
'minor_amount': minor_amount,
'capture_method': 'manual' if self.capture_manually else 'automatic',
'is_test': self.state == 'test',
'locale': clover_locale,
'billing_details': {
'name': partner.name or '',
'email': partner.email or '',
@@ -411,7 +874,7 @@ class PaymentProvider(models.Model):
# === BUSINESS METHODS - TERMINAL (REST Pay Display Cloud API) === #
def _clover_terminal_request(self, method, endpoint, serial_number=None,
payload=None, params=None):
payload=None, params=None, _retry=True):
"""Make a request to the Clover REST Pay Display Cloud API.
Sends commands to Clover terminals through Clover's cloud (Cloud Pay Display).
@@ -434,8 +897,12 @@ class PaymentProvider(models.Model):
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': f'Bearer {self.clover_rest_api_token or self.clover_api_key}',
'X-POS-ID': 'FusionCloverOdoo',
'Authorization': f'Bearer {self._clover_get_platform_token()}',
# X-POS-Id MUST be the Remote App ID (RAID) from the Clover
# developer dashboard (App Type > Web > "is integration of an
# existing POS" = Yes). Falls back to a static identifier in
# sandbox where some merchants use the legacy free-form value.
'X-POS-Id': self.clover_remote_app_id or 'FusionCloverOdoo',
}
if serial_number:
headers['X-Clover-Device-Id'] = serial_number
@@ -472,6 +939,14 @@ class PaymentProvider(models.Model):
_logger.error("Clover Terminal returned non-JSON: %s", response.text[:500])
raise ValidationError(_("Clover terminal returned an invalid response."))
if response.status_code == 401 and _retry and self.clover_oauth_refresh_token:
_logger.info("Clover Terminal 401, attempting token refresh + retry")
if self._clover_refresh_oauth_token():
return self._clover_terminal_request(
method, endpoint, serial_number=serial_number,
payload=payload, params=params, _retry=False,
)
if response.status_code >= 400:
error_msg = result.get('message', result.get('error', 'Unknown error'))
_logger.error(

View File

@@ -442,6 +442,51 @@ class PaymentTransaction(models.Model):
return tx
def _extract_amount_data(self, payment_data):
"""Override of `payment` for the Odoo 19 amount-tamper check.
Odoo's ``_validate_amount`` calls this and KErrors if the dict
doesn't contain ``amount`` + ``currency_code``. Other providers
always include these in their ``payment_data``; we historically
didn't. Extract from the Clover charge response shape:
- source token charges (Ecommerce):
``{'amount': <cents>, 'currency': 'usd', ...}`` (raw Clover
response usually shoved into ``payment_data['raw']``)
- terminal payments: ``payment_data['amount']`` (we set this
explicitly when we build the dict)
Returns ``None`` to opt out of the amount check when no usable
amount field is present — the alternative (hard-failing the
transaction) is worse than skipping validation, and our charge
amounts are server-controlled (we send them; Clover doesn't
invent new amounts).
"""
if self.provider_code != 'clover':
return super()._extract_amount_data(payment_data)
amount_minor = payment_data.get('amount')
currency_code = payment_data.get('currency')
# Some code paths put the raw Clover response under 'raw' or pass
# the whole charge dict — try those fallbacks before opting out.
if amount_minor is None and isinstance(payment_data.get('raw'), dict):
amount_minor = payment_data['raw'].get('amount')
currency_code = payment_data['raw'].get('currency') or currency_code
if amount_minor is None or not currency_code:
return None # opt out of amount validation (server-controlled)
# Clover amounts are in minor units (cents for USD/CAD, no
# decimals for JPY/KRW). Convert back to major units using the
# transaction's currency to know the divisor.
from odoo.addons.fusion_clover import utils as clover_utils
amount_major = clover_utils.parse_clover_amount(
int(amount_minor), self.currency_id,
)
return {
'amount': float(amount_major),
'currency_code': str(currency_code).upper(),
}
def _apply_updates(self, payment_data):
"""Override of `payment` to update the transaction based on Clover data."""
if self.provider_code != 'clover':

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -1,33 +1,40 @@
/** @odoo-module **/
import { _t } from '@web/core/l10n/translation';
import { patch } from '@web/core/utils/patch';
import { rpc } from '@web/core/network/rpc';
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
import { rpc } from "@web/core/network/rpc";
import { loadJS } from "@web/core/assets";
import { PaymentForm } from '@payment/interactions/payment_form';
import { PaymentForm } from "@payment/interactions/payment_form";
const CLOVER_SDK_URL_TEST = "https://checkout.sandbox.dev.clover.com/sdk.js";
const CLOVER_SDK_URL_PROD = "https://checkout.clover.com/sdk.js";
patch(PaymentForm.prototype, {
setup() {
super.setup();
this.cloverFormData = {};
this._detectedCardType = 'other';
this._selectedCardType = 'other';
this.cloverInstance = null;
this.cloverElements = null;
this.cloverMountedElements = {};
this._detectedCardType = "other";
this._selectedCardType = "other";
},
// #=== DOM MANIPULATION ===#
async _prepareInlineForm(providerId, providerCode, paymentOptionId, paymentMethodCode, flow) {
if (providerCode !== 'clover') {
if (providerCode !== "clover") {
await super._prepareInlineForm(...arguments);
return;
}
if (flow === 'token') {
if (flow === "token") {
return;
}
this._setPaymentFlow('direct');
this._setPaymentFlow("direct");
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
const inlineForm = this._getInlineForm(radio);
@@ -37,45 +44,135 @@ patch(PaymentForm.prototype, {
return;
}
const rawValues = cloverContainer.dataset['cloverInlineFormValues'];
const rawValues = cloverContainer.dataset["cloverInlineFormValues"];
if (rawValues) {
this.cloverFormData = JSON.parse(rawValues);
}
this._setupCardFormatting(cloverContainer);
this._setupTerminalToggle(cloverContainer);
this._setupSurcharge(cloverContainer);
try {
await this._loadCloverSDK(this.cloverFormData.is_test);
this._initialiseCloverIframe(cloverContainer);
} catch (error) {
this._showCloverSdkError(
cloverContainer,
error?.message || _t("Could not initialise the Clover payment form."),
);
}
},
_detectCardBrand(number) {
const num = (number || '').replace(/\D/g, '');
if (num.length < 2) return 'other';
const prefix2 = num.substring(0, 2);
if (prefix2 === '34' || prefix2 === '37') return 'amex';
if (num[0] === '4') return 'visa';
const p2 = parseInt(prefix2, 10);
if (p2 >= 51 && p2 <= 55) return 'mastercard';
if (num.length >= 4) {
const p4 = parseInt(num.substring(0, 4), 10);
if (p4 >= 2221 && p4 <= 2720) return 'mastercard';
async _loadCloverSDK(isTest) {
const url = isTest ? CLOVER_SDK_URL_TEST : CLOVER_SDK_URL_PROD;
await loadJS(url);
if (typeof window.Clover === "undefined") {
throw new Error(_t("Clover SDK failed to load."));
}
return 'other';
},
_initialiseCloverIframe(container) {
const data = this.cloverFormData;
const apiAccessKey = data.public_key;
const merchantId = data.merchant_id;
if (!apiAccessKey) {
this._showCloverSdkError(
container,
_t("This Clover provider has no Public API Key (PAKMS) configured. Add it on the payment provider record before customers can pay online."),
);
return;
}
const cloverConfig = { merchantId };
if (data.locale) {
cloverConfig.locale = data.locale;
}
// eslint-disable-next-line no-undef
this.cloverInstance = new Clover(apiAccessKey, cloverConfig);
this.cloverElements = this.cloverInstance.elements();
const styles = {
input: {
fontSize: "16px",
fontFamily: "inherit",
color: "#212529",
padding: "10px 12px",
},
".invalid": { color: "#dc3545" },
};
const mounts = [
["CARD_NUMBER", "#clover-card-number", "#clover-card-number-errors"],
["CARD_DATE", "#clover-card-date", "#clover-card-date-errors"],
["CARD_CVV", "#clover-card-cvv", "#clover-card-cvv-errors"],
["CARD_POSTAL_CODE", "#clover-card-postal-code", "#clover-card-postal-code-errors"],
];
for (const [type, mountSelector, errorSelector] of mounts) {
const element = this.cloverElements.create(type, styles);
element.mount(mountSelector);
element.addEventListener("change", (event) => {
const errorEl = container.querySelector(errorSelector);
if (!errorEl) return;
if (event && event[type] && event[type].error) {
errorEl.textContent = event[type].error;
} else {
errorEl.textContent = "";
}
if (type === "CARD_NUMBER" && event && event.CARD_NUMBER) {
const brand = (event.CARD_NUMBER.brand || "").toLowerCase();
if (brand) {
const mapped = this._mapCloverBrandToOdoo(brand);
if (mapped !== this._detectedCardType) {
this._detectedCardType = mapped;
if (mapped !== "other") {
this._selectedCardType = mapped;
}
this._updateSurchargeDisplay(container);
}
}
}
});
this.cloverMountedElements[type] = element;
}
},
_mapCloverBrandToOdoo(brand) {
const map = {
visa: "visa",
mastercard: "mastercard",
mc: "mastercard",
amex: "amex",
"american_express": "amex",
discover: "other",
"diners_club": "other",
jcb: "other",
unionpay: "other",
unknown: "other",
};
return map[brand] || "other";
},
_showCloverSdkError(container, message) {
const banner = container.querySelector("#clover-sdk-error");
const messageEl = container.querySelector("#clover-sdk-error-message");
if (banner) banner.classList.remove("d-none");
if (messageEl) messageEl.textContent = message;
},
_setupSurcharge(container) {
const surchargeConfig = this.cloverFormData.surcharge;
if (!surchargeConfig || !surchargeConfig.enabled) return;
const cardTypeSection = container.querySelector('.o_clover_card_type_section');
const surchargeNotice = container.querySelector('.o_clover_surcharge_notice');
const cardTypeSection = container.querySelector(".o_clover_card_type_section");
if (cardTypeSection) {
cardTypeSection.style.display = 'block';
cardTypeSection.style.display = "block";
}
const cardTypeRadios = container.querySelectorAll('input[name="clover_card_type"]');
cardTypeRadios.forEach(radio => {
radio.addEventListener('change', () => {
cardTypeRadios.forEach((radio) => {
radio.addEventListener("change", () => {
this._selectedCardType = radio.value;
this._updateSurchargeDisplay(container);
});
@@ -88,137 +185,82 @@ patch(PaymentForm.prototype, {
const surchargeConfig = this.cloverFormData.surcharge;
if (!surchargeConfig || !surchargeConfig.enabled) return;
const cardType = this._detectedCardType !== 'other'
const cardType = this._detectedCardType !== "other"
? this._detectedCardType
: this._selectedCardType;
const rate = surchargeConfig[cardType] || surchargeConfig['other'] || 0;
const amount = this.cloverFormData.minor_amount || 0;
const rate = surchargeConfig[cardType] || surchargeConfig["other"] || 0;
const minorAmount = this.cloverFormData.minor_amount || 0;
const baseAmount = amount / 100;
const baseAmount = minorAmount / 100;
const feeAmount = Math.round(baseAmount * rate) / 100;
const rateEl = container.querySelector('#clover_surcharge_rate');
const amountEl = container.querySelector('#clover_surcharge_amount');
const noticeEl = container.querySelector('.o_clover_surcharge_notice');
const rateEl = container.querySelector("#clover_surcharge_rate");
const amountEl = container.querySelector("#clover_surcharge_amount");
const noticeEl = container.querySelector(".o_clover_surcharge_notice");
if (rateEl) rateEl.textContent = rate.toFixed(2);
if (amountEl) amountEl.textContent = `$${feeAmount.toFixed(2)}`;
if (noticeEl) {
noticeEl.style.display = rate > 0 ? 'block' : 'none';
noticeEl.style.display = rate > 0 ? "block" : "none";
}
const radioToCheck = container.querySelector(
`input[name="clover_card_type"][value="${cardType}"]`
`input[name="clover_card_type"][value="${cardType}"]`,
);
if (radioToCheck && !radioToCheck.checked) {
radioToCheck.checked = true;
}
},
_setupCardFormatting(container) {
const cardInput = container.querySelector('#clover_card_number');
if (cardInput) {
cardInput.addEventListener('input', (e) => {
let value = e.target.value.replace(/\D/g, '');
let formatted = '';
for (let i = 0; i < value.length && i < 16; i++) {
if (i > 0 && i % 4 === 0) {
formatted += ' ';
}
formatted += value[i];
}
e.target.value = formatted;
const detected = this._detectCardBrand(value);
if (detected !== this._detectedCardType) {
this._detectedCardType = detected;
if (detected !== 'other') {
this._selectedCardType = detected;
}
this._updateSurchargeDisplay(
e.target.closest('.o_clover_payment_form')
);
}
});
}
const expiryInput = container.querySelector('#clover_expiry');
if (expiryInput) {
expiryInput.addEventListener('input', (e) => {
let value = e.target.value.replace(/\D/g, '');
if (value.length >= 2) {
value = value.substring(0, 2) + '/' + value.substring(2, 4);
}
e.target.value = value;
});
}
},
_setupTerminalToggle(container) {
const terminalCheckbox = container.querySelector('#clover_use_terminal');
const terminalSelect = container.querySelector('#clover_terminal_select_wrapper');
const cardFields = container.querySelectorAll(
'#clover_card_number, #clover_expiry, #clover_cvv, #clover_cardholder'
);
const terminalCheckbox = container.querySelector("#clover_use_terminal");
const terminalSelect = container.querySelector("#clover_terminal_select_wrapper");
const iframeForm = container.querySelector(".o_clover_iframe_form");
if (!terminalCheckbox) {
return;
}
terminalCheckbox.addEventListener('change', () => {
terminalCheckbox.addEventListener("change", () => {
if (terminalCheckbox.checked) {
if (terminalSelect) {
terminalSelect.style.display = 'block';
}
cardFields.forEach(f => {
f.closest('.mb-3').style.display = 'none';
f.removeAttribute('required');
});
if (terminalSelect) terminalSelect.style.display = "block";
if (iframeForm) iframeForm.style.display = "none";
this._loadTerminals(container);
} else {
if (terminalSelect) {
terminalSelect.style.display = 'none';
}
cardFields.forEach(f => {
f.closest('.mb-3').style.display = 'block';
if (f.id !== 'clover_cardholder') {
f.setAttribute('required', 'required');
}
});
if (terminalSelect) terminalSelect.style.display = "none";
if (iframeForm) iframeForm.style.display = "block";
}
});
},
async _loadTerminals(container) {
const selectEl = container.querySelector('#clover_terminal_select');
const selectEl = container.querySelector("#clover_terminal_select");
if (!selectEl || selectEl.options.length > 1) {
return;
}
try {
const terminals = await rpc('/payment/clover/terminals', {
const terminals = await rpc("/payment/clover/terminals", {
provider_id: this.cloverFormData.provider_id,
});
selectEl.innerHTML = '';
selectEl.innerHTML = "";
if (terminals && terminals.length > 0) {
terminals.forEach(t => {
const option = document.createElement('option');
terminals.forEach((t) => {
const option = document.createElement("option");
option.value = t.id;
option.textContent = `${t.name} (${t.status})`;
selectEl.appendChild(option);
});
} else {
const option = document.createElement('option');
option.value = '';
option.textContent = _t('No terminals available');
const option = document.createElement("option");
option.value = "";
option.textContent = _t("No terminals available");
selectEl.appendChild(option);
}
} catch {
const option = document.createElement('option');
option.value = '';
option.textContent = _t('Failed to load terminals');
const option = document.createElement("option");
option.value = "";
option.textContent = _t("Failed to load terminals");
selectEl.appendChild(option);
}
},
@@ -226,18 +268,18 @@ patch(PaymentForm.prototype, {
// #=== PAYMENT FLOW ===#
async _initiatePaymentFlow(providerCode, paymentOptionId, paymentMethodCode, flow) {
if (providerCode !== 'clover' || flow === 'token') {
if (providerCode !== "clover" || flow === "token") {
await super._initiatePaymentFlow(...arguments);
return;
}
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
const inlineForm = this._getInlineForm(radio);
const useTerminal = inlineForm.querySelector('#clover_use_terminal');
const useTerminal = inlineForm.querySelector("#clover_use_terminal");
if (useTerminal && useTerminal.checked) {
const terminalId = inlineForm.querySelector('#clover_terminal_select').value;
if (!terminalId) {
const terminalSelect = inlineForm.querySelector("#clover_terminal_select");
if (!terminalSelect || !terminalSelect.value) {
this._displayErrorDialog(
_t("Terminal Required"),
_t("Please select a terminal device."),
@@ -245,64 +287,27 @@ patch(PaymentForm.prototype, {
this._enableButton();
return;
}
} else {
const validationError = this._validateCardInputs(inlineForm);
if (validationError) {
this._displayErrorDialog(
_t("Invalid Card Details"),
validationError,
);
this._enableButton();
return;
}
} else if (!this.cloverInstance) {
this._displayErrorDialog(
_t("Payment form not ready"),
_t("The Clover payment form has not finished loading. Please wait a moment and try again."),
);
this._enableButton();
return;
}
await super._initiatePaymentFlow(...arguments);
},
_validateCardInputs(inlineForm) {
const cardNumber = inlineForm.querySelector('#clover_card_number');
const expiry = inlineForm.querySelector('#clover_expiry');
const cvv = inlineForm.querySelector('#clover_cvv');
const cardDigits = cardNumber.value.replace(/\D/g, '');
if (cardDigits.length < 13 || cardDigits.length > 19) {
return _t("Please enter a valid card number.");
}
const expiryValue = expiry.value;
if (!/^\d{2}\/\d{2}$/.test(expiryValue)) {
return _t("Please enter a valid expiry date (MM/YY).");
}
const [month, year] = expiryValue.split('/').map(Number);
if (month < 1 || month > 12) {
return _t("Invalid expiry month.");
}
const now = new Date();
const expiryDate = new Date(2000 + year, month);
if (expiryDate <= now) {
return _t("Card has expired.");
}
const cvvValue = cvv.value.replace(/\D/g, '');
if (cvvValue.length < 3 || cvvValue.length > 4) {
return _t("Please enter a valid CVV.");
}
return null;
},
async _processDirectFlow(providerCode, paymentOptionId, paymentMethodCode, processingValues) {
if (providerCode !== 'clover') {
if (providerCode !== "clover") {
await super._processDirectFlow(...arguments);
return;
}
const radio = document.querySelector('input[name="o_payment_radio"]:checked');
const inlineForm = this._getInlineForm(radio);
const useTerminal = inlineForm.querySelector('#clover_use_terminal');
const useTerminal = inlineForm.querySelector("#clover_use_terminal");
if (useTerminal && useTerminal.checked) {
await this._processTerminalPayment(processingValues, inlineForm);
@@ -313,36 +318,73 @@ patch(PaymentForm.prototype, {
_getSelectedCardType(inlineForm) {
const checked = inlineForm.querySelector('input[name="clover_card_type"]:checked');
return checked ? checked.value : 'other';
return checked ? checked.value : "other";
},
async _processCardPayment(processingValues, inlineForm) {
const cardNumber = inlineForm.querySelector('#clover_card_number').value.replace(/\D/g, '');
const expiry = inlineForm.querySelector('#clover_expiry').value;
const cvv = inlineForm.querySelector('#clover_cvv').value;
const cardholder = inlineForm.querySelector('#clover_cardholder').value;
const cardType = this._detectedCardType !== 'other'
if (!this.cloverInstance) {
this._displayErrorDialog(
_t("Payment form error"),
_t("Clover SDK has not been initialised. Please reload the page."),
);
this._enableButton();
return;
}
const cardType = this._detectedCardType !== "other"
? this._detectedCardType
: this._getSelectedCardType(inlineForm);
const [expMonth, expYear] = expiry.split('/').map(Number);
let result;
try {
result = await this.cloverInstance.createToken();
} catch (error) {
this._displayErrorDialog(
_t("Card validation error"),
error?.message || _t("Could not validate the card details."),
);
this._enableButton();
return;
}
if (!result || result.errors) {
const messages = [];
if (result && result.errors) {
Object.values(result.errors).forEach((value) => {
if (typeof value === "string") {
messages.push(value);
} else if (value && value.error) {
messages.push(value.error);
}
});
}
this._displayErrorDialog(
_t("Invalid Card Details"),
messages.join(" ") || _t("Please check the card details and try again."),
);
this._enableButton();
return;
}
const token = result.token;
if (!token) {
this._displayErrorDialog(
_t("Tokenization Failed"),
_t("Clover did not return a card token. Please try again."),
);
this._enableButton();
return;
}
try {
const result = await rpc('/payment/clover/process_card', {
const response = await rpc("/payment/clover/process_card", {
reference: processingValues.reference,
card_number: cardNumber,
exp_month: expMonth,
exp_year: 2000 + expYear,
cvv: cvv,
cardholder_name: cardholder,
card_token: token,
card_type: cardType,
});
if (result.error) {
this._displayErrorDialog(
_t("Payment Failed"),
result.error,
);
if (response.error) {
this._displayErrorDialog(_t("Payment Failed"), response.error);
this._enableButton();
return;
}
@@ -358,25 +400,19 @@ patch(PaymentForm.prototype, {
},
async _processTerminalPayment(processingValues, inlineForm) {
const terminalId = inlineForm.querySelector('#clover_terminal_select').value;
const terminalId = inlineForm.querySelector("#clover_terminal_select").value;
const cardType = this._getSelectedCardType(inlineForm);
try {
const result = await rpc('/payment/clover/send_to_terminal', {
const result = await rpc("/payment/clover/send_to_terminal", {
reference: processingValues.reference,
terminal_id: parseInt(terminalId),
card_type: cardType,
});
if (result.error) {
this._displayErrorDialog(
_t("Terminal Payment Failed"),
result.error,
);
this._displayErrorDialog(_t("Terminal Payment Failed"), result.error);
this._enableButton();
return;
}
this._showTerminalWaitingScreen(processingValues, terminalId);
} catch (error) {
this._displayErrorDialog(
@@ -388,7 +424,7 @@ patch(PaymentForm.prototype, {
},
_showTerminalWaitingScreen(processingValues, terminalId) {
const container = document.querySelector('.o_clover_payment_form');
const container = document.querySelector(".o_clover_payment_form");
if (container) {
container.innerHTML = `
<div class="text-center p-4">
@@ -405,14 +441,12 @@ patch(PaymentForm.prototype, {
</div>
`;
}
this._pollTerminalStatus(processingValues, terminalId);
},
async _pollTerminalStatus(processingValues, terminalId, attempt = 0) {
const maxAttempts = 60;
const pollInterval = 3000;
if (attempt >= maxAttempts) {
this._displayErrorDialog(
_t("Timeout"),
@@ -421,26 +455,26 @@ patch(PaymentForm.prototype, {
this._enableButton();
return;
}
try {
const result = await rpc('/payment/clover/terminal_status', {
const result = await rpc("/payment/clover/terminal_status", {
reference: processingValues.reference,
terminal_id: parseInt(terminalId),
});
const statusEl = document.getElementById('clover_terminal_status');
if (result.status === 'CLOSED' || result.status === 'CAPTURED'
|| result.status === 'AUTH' || result.status === 'AUTHORIZED') {
const statusEl = document.getElementById("clover_terminal_status");
if (
result.status === "CLOSED" || result.status === "CAPTURED"
|| result.status === "AUTH" || result.status === "AUTHORIZED"
) {
if (statusEl) {
statusEl.textContent = _t("Payment completed! Redirecting...");
}
window.location.href = processingValues.return_url;
return;
}
if (result.status === 'DECLINED' || result.status === 'FAILED'
|| result.status === 'FAIL') {
if (
result.status === "DECLINED" || result.status === "FAILED"
|| result.status === "FAIL"
) {
this._displayErrorDialog(
_t("Payment Declined"),
_t("The payment was declined at the terminal."),
@@ -448,11 +482,9 @@ patch(PaymentForm.prototype, {
this._enableButton();
return;
}
if (statusEl) {
statusEl.textContent = _t("Status: ") + (result.status || _t("Pending"));
}
setTimeout(
() => this._pollTerminalStatus(processingValues, terminalId, attempt + 1),
pollInterval,

View File

@@ -155,23 +155,45 @@ def build_charge_payload(amount, currency, source_token, capture=True,
def build_refund_payload(charge_id, amount=None, currency=None, reason=''):
"""Build a Clover refund payload.
"""Build a Clover ``/v1/refunds`` payload.
The Clover Ecommerce ``/v1/refunds`` endpoint accepts ONLY the
``charge`` field — including any other field triggers a 400
"Invalid JSON format" response. ``amount`` and ``reason`` arguments
are accepted for backwards-compat but ignored by this endpoint.
For partial refunds, callers should use the
``/v1/payments/{paymentId}/refunds`` endpoint instead, which takes
``{"amount": <cents>}`` or ``{"fullRefund": true}``.
https://docs.clover.com/dev/docs/ecommerce-refunding-payments
:param str charge_id: The Clover charge ID to refund.
:param float amount: Optional partial refund amount in major currency units.
:param recordset currency: Optional currency record (needed for partial refunds).
:param str reason: Optional reason for the refund.
:param float amount: IGNORED on this endpoint (full refund only).
Use a /payments/{id}/refunds call for partial.
:param recordset currency: IGNORED.
:param str reason: IGNORED on this endpoint.
:return: The Clover-formatted refund payload.
:rtype: dict
"""
payload = {
'charge': charge_id,
}
return {'charge': charge_id}
def build_payment_refund_payload(amount=None, currency=None, full_refund=False):
"""Build a Clover ``/v1/payments/{paymentId}/refunds`` payload.
Used for partial refunds (the ``/v1/refunds`` endpoint is full-refund
only). Either pass ``full_refund=True`` to refund the entire payment,
or ``amount`` (with ``currency``) for a partial.
:param float amount: Partial refund amount in major currency units.
:param recordset currency: Currency record for the partial refund.
:param bool full_refund: If True, refund the entire payment.
:return: The Clover-formatted payment-refund payload.
:rtype: dict
"""
if full_refund:
return {'fullRefund': True}
if amount is not None and currency:
payload['amount'] = format_clover_amount(amount, currency)
if reason:
payload['reason'] = reason
return payload
return {'amount': format_clover_amount(amount, currency)}
return {'fullRefund': True}

View File

@@ -100,7 +100,7 @@
name="action_sync_terminals"
class="btn-secondary"
icon="fa-refresh"
invisible="not clover_merchant_id or (not clover_rest_api_token and not clover_api_key)"
invisible="not clover_merchant_id or (not clover_rest_api_token and not clover_api_key and not clover_oauth_access_token)"
colspan="2"/>
</xpath>
</field>

View File

@@ -1,7 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Inline payment form template for Clover -->
<!-- Inline payment form template for Clover.
The card data fields are RENDERED BY CLOVER in iframes mounted
into the empty <div id="clover-card-*"> containers below. Card
data NEVER touches Odoo - the Clover iframe SDK posts directly
to Clover's tokenization endpoint and returns a clv_xxx token
which is what we then send to the backend. This is the only
way to keep Westin / NEXA out of PCI SAQ-D scope. -->
<template id="inline_form">
<t t-set="inline_form_values"
t-value="provider_sudo._clover_get_inline_form_values(
@@ -16,8 +23,10 @@
class="o_clover_payment_form"
t-att-data-clover-inline-form-values="inline_form_values">
<!-- Terminal toggle -->
<div class="mb-3 form-check">
<!-- Terminal toggle (back-office staff or in-person checkout) -->
<div class="mb-3 form-check"
name="o_clover_terminal_toggle_wrapper"
style="display:none;">
<input type="checkbox" class="form-check-input"
id="clover_use_terminal" name="use_terminal"/>
<label class="form-check-label" for="clover_use_terminal">
@@ -26,7 +35,6 @@
</label>
</div>
<!-- Terminal select (hidden by default) -->
<div class="mb-3" id="clover_terminal_select_wrapper" style="display:none;">
<label class="form-label" for="clover_terminal_select">Select Terminal</label>
<select class="form-select" id="clover_terminal_select" name="terminal_id">
@@ -34,53 +42,51 @@
</select>
</div>
<!-- Card number input -->
<div class="mb-3">
<label class="form-label" for="clover_card_number">Card Number</label>
<input type="text" class="form-control"
id="clover_card_number"
name="card_number"
placeholder="4111 1111 1111 1111"
maxlength="19"
autocomplete="cc-number"
required="required"/>
</div>
<!-- Expiry and CVV row -->
<div class="row mb-3">
<div class="col-6">
<label class="form-label" for="clover_expiry">Expiry Date</label>
<input type="text" class="form-control"
id="clover_expiry"
name="expiry"
placeholder="MM/YY"
maxlength="5"
autocomplete="cc-exp"
required="required"/>
<!-- Clover.js iframe mount points (card data lives inside these
iframes, never in our DOM). -->
<div class="o_clover_iframe_form" name="o_clover_iframe_form">
<div class="mb-3">
<label class="form-label" for="clover-card-number">Card Number</label>
<div id="clover-card-number" class="form-control"
style="height:42px; padding:0;"/>
<div class="invalid-feedback d-block"
id="clover-card-number-errors" role="alert"/>
</div>
<div class="col-6">
<label class="form-label" for="clover_cvv">CVV</label>
<input type="password" class="form-control"
id="clover_cvv"
name="cvv"
placeholder="123"
maxlength="4"
autocomplete="cc-csc"
required="required"/>
<div class="row mb-3">
<div class="col-6">
<label class="form-label" for="clover-card-date">Expiry (MM/YY)</label>
<div id="clover-card-date" class="form-control"
style="height:42px; padding:0;"/>
<div class="invalid-feedback d-block"
id="clover-card-date-errors" role="alert"/>
</div>
<div class="col-6">
<label class="form-label" for="clover-card-cvv">CVV</label>
<div id="clover-card-cvv" class="form-control"
style="height:42px; padding:0;"/>
<div class="invalid-feedback d-block"
id="clover-card-cvv-errors" role="alert"/>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="clover-card-postal-code">Postal Code</label>
<div id="clover-card-postal-code" class="form-control"
style="height:42px; padding:0;"/>
<div class="invalid-feedback d-block"
id="clover-card-postal-code-errors" role="alert"/>
</div>
<div class="alert alert-warning d-none"
id="clover-sdk-error" role="alert">
<i class="fa fa-exclamation-triangle me-1"/>
<span id="clover-sdk-error-message">
Could not load the Clover payment form.
</span>
</div>
</div>
<!-- Cardholder name -->
<div class="mb-3">
<label class="form-label" for="clover_cardholder">Cardholder Name</label>
<input type="text" class="form-control"
id="clover_cardholder"
name="cardholder_name"
placeholder="John Doe"
autocomplete="cc-name"/>
</div>
<!-- Card type selector -->
<!-- Card type selector (only visible if surcharge is on AND the
iframe SDK couldn't auto-detect the brand from the typed
card number, e.g. for "Other"). -->
<div class="mb-3 o_clover_card_type_section" style="display:none;">
<label class="form-label">Card Type</label>
<div class="d-flex gap-2 flex-wrap">
@@ -107,7 +113,6 @@
</div>
</div>
<!-- Surcharge notice -->
<div class="mb-3 o_clover_surcharge_notice" style="display:none;">
<div class="alert alert-info py-2 mb-0">
<small>

View File

@@ -23,24 +23,40 @@
<field name="clover_rest_api_token"
password="True"
placeholder="From Clover Dashboard: Setup > API Tokens"/>
<separator string="OAuth (Optional)"/>
<separator string="Nexa Developer App (OAuth)"/>
<field name="clover_app_id"
placeholder="App ID (for OAuth flow)"/>
placeholder="App ID from Clover Developer Dashboard"/>
<label for="clover_app_secret"/>
<div class="o_row" col="2">
<field name="clover_app_secret" password="True"/>
</div>
<field name="clover_remote_app_id"
placeholder="Remote App ID (RAID) - X-POS-Id header"/>
<separator string="OAuth Status (auto-populated by Connect to Clover)"/>
<field name="clover_oauth_access_token"
string="Access Token Status"
readonly="1"
password="True"/>
<field name="clover_oauth_token_expiry" readonly="1"/>
</group>
</group>
<group name="provider_credentials" position="after">
<group string="Clover Actions"
invisible="code != 'clover'" name="clover_actions"
col="4">
<button string="Connect to Clover"
type="object"
name="action_clover_oauth_connect"
class="btn-primary"
icon="fa-link"
invisible="not clover_app_id or not clover_app_secret"
colspan="2"
help="Redirects you to Clover to authorise this Odoo to act on the merchant's behalf. Required for terminal payments and webhooks."/>
<button string="Test Connection"
type="object"
name="action_clover_test_connection"
class="btn-primary"
invisible="not clover_merchant_id or (not clover_api_key and not clover_rest_api_token)"
class="btn-secondary"
invisible="not clover_merchant_id or (not clover_api_key and not clover_rest_api_token and not clover_oauth_access_token)"
colspan="2"/>
</group>
<group string="Terminal Settings"

View File

@@ -10,107 +10,63 @@
<app data-string="Fusion Clover" string="Fusion Clover" name="fusion_clover"
groups="fusion_clover.group_fusion_clover_admin">
<h2>Credit Card Surcharge</h2>
<div class="row mt-4 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="clover_surcharge_enabled"/>
</div>
<div class="o_setting_right_pane">
<span class="o_form_label">Credit Card Processing Fee</span>
<div class="text-muted">
Automatically add a surcharge line to invoices when collecting payment
via Clover. The fee is calculated as a percentage of the invoice total.
<block title="Credit Card Surcharge"
help="Automatically add a credit card processing fee to invoices when collecting payment via Clover.">
<setting id="fusion_clover_surcharge"
string="Enable Surcharge"
help="When enabled, a percentage-based fee line is added to the invoice before the Clover charge is created. The percentage depends on the card brand selected.">
<field name="clover_surcharge_enabled"/>
<div class="content-group" invisible="not clover_surcharge_enabled">
<div class="row mt16">
<label for="clover_surcharge_visa_rate"
string="Visa (%)"
class="col-lg-5 o_light_label"/>
<field name="clover_surcharge_visa_rate"/>
</div>
</div>
</div>
</div>
<div class="row mt-4 o_settings_container"
invisible="not clover_surcharge_enabled">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Surcharge Rates by Card Type</span>
<div class="text-muted mb-2">
Configure the processing fee percentage for each card brand.
<div class="row">
<label for="clover_surcharge_mastercard_rate"
string="Mastercard (%)"
class="col-lg-5 o_light_label"/>
<field name="clover_surcharge_mastercard_rate"/>
</div>
<div class="mt-2">
<div class="row mb-2">
<label for="clover_surcharge_visa_rate"
class="col-5 col-form-label">Visa</label>
<div class="col-4">
<field name="clover_surcharge_visa_rate" class="o_input"/>
</div>
<div class="col-1 col-form-label">%</div>
</div>
<div class="row mb-2">
<label for="clover_surcharge_mastercard_rate"
class="col-5 col-form-label">Mastercard</label>
<div class="col-4">
<field name="clover_surcharge_mastercard_rate" class="o_input"/>
</div>
<div class="col-1 col-form-label">%</div>
</div>
<div class="row mb-2">
<label for="clover_surcharge_amex_rate"
class="col-5 col-form-label">American Express</label>
<div class="col-4">
<field name="clover_surcharge_amex_rate" class="o_input"/>
</div>
<div class="col-1 col-form-label">%</div>
</div>
<div class="row mb-2">
<label for="clover_surcharge_debit_rate"
class="col-5 col-form-label">Debit</label>
<div class="col-4">
<field name="clover_surcharge_debit_rate" class="o_input"/>
</div>
<div class="col-1 col-form-label">%</div>
</div>
<div class="row mb-2">
<label for="clover_surcharge_other_rate"
class="col-5 col-form-label">Other Cards</label>
<div class="col-4">
<field name="clover_surcharge_other_rate" class="o_input"/>
</div>
<div class="col-1 col-form-label">%</div>
</div>
<div class="row">
<label for="clover_surcharge_amex_rate"
string="Amex (%)"
class="col-lg-5 o_light_label"/>
<field name="clover_surcharge_amex_rate"/>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Surcharge Product</span>
<div class="text-muted mb-2">
The service product used for the processing fee invoice line.
<div class="row">
<label for="clover_surcharge_debit_rate"
string="Debit (%)"
class="col-lg-5 o_light_label"/>
<field name="clover_surcharge_debit_rate"/>
</div>
<div class="mt-2">
<div class="row">
<label for="clover_surcharge_other_rate"
string="Other (%)"
class="col-lg-5 o_light_label"/>
<field name="clover_surcharge_other_rate"/>
</div>
<div class="row mt16">
<label for="clover_surcharge_product_id"
string="Fee Product"
class="col-lg-5 o_light_label"/>
<field name="clover_surcharge_product_id"
domain="[('type', '=', 'service')]"/>
domain="[('type', '=', 'service')]"
options="{'no_create': True, 'no_open': True}"/>
</div>
</div>
</div>
</div>
<h2>Quick Links</h2>
<div class="row mt-4 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Payment Provider</span>
<div class="text-muted mb-2">
Configure your Clover API credentials and merchant ID.
</div>
<div class="mt-2">
<button name="action_open_clover_provider"
type="object"
string="Configure Payment Provider"
class="btn-link"
icon="fa-arrow-right"/>
</div>
</div>
</div>
</div>
</setting>
<setting id="fusion_clover_provider_link"
string="Payment Provider"
help="Open the Clover payment provider record to configure your Merchant ID, Ecommerce API tokens and REST API token.">
<button name="action_open_clover_provider"
type="object"
string="Configure Clover"
class="btn-link"
icon="oi-arrow-right"/>
</setting>
</block>
</app>
</xpath>

View File

@@ -464,24 +464,26 @@ class CloverPaymentWizard(models.TransientModel):
provider = self._get_provider_sudo()
capture = not provider.capture_manually
minor_amount = clover_utils.format_clover_amount(
self.amount, self.currency_id,
# Tokenize the card client-server FIRST (Clover's tokenization
# endpoint), then charge using only the token. Raw PAN never
# reaches /v1/charges and is never persisted in Odoo.
card_token = provider._clover_tokenize_card(
card_number=self.card_number,
exp_month=int(self.exp_month),
exp_year=int(self.exp_year) if len(self.exp_year) == 4
else 2000 + int(self.exp_year),
cvv=self.cvv,
cardholder_name=self.cardholder_name or '',
)
payload = {
'amount': minor_amount,
'currency': self.currency_id.name.lower(),
'capture': capture,
'ecomind': 'moto',
'description': reference,
'source': self.card_number.replace(' ', ''),
'metadata': {
'odoo_reference': reference,
},
}
result = provider._clover_make_ecom_request(
'POST', 'v1/charges', payload=payload,
result = provider._clover_create_charge(
source_token=card_token,
amount=self.amount,
currency=self.currency_id,
capture=capture,
description=reference,
ecomind='moto',
metadata={'odoo_reference': reference},
)
charge_id = result.get('id', '')
@@ -497,6 +499,11 @@ class CloverPaymentWizard(models.TransientModel):
'clover_charge_id': charge_id,
'clover_status': status,
'source': result.get('source', {}),
# Pass amount + currency back so Odoo 19's amount-tamper
# check (_validate_amount) can verify Clover charged the
# exact amount we asked for.
'amount': result.get('amount'),
'currency': result.get('currency'),
}
if status == 'failed':
@@ -586,6 +593,15 @@ class CloverPaymentWizard(models.TransientModel):
raise UserError(_("Please enter a valid expiry year."))
if not self.cvv or not self.cvv.isdigit():
raise UserError(_("Please enter the CVV."))
# Clover production rejects cards without a 2-part name
# ("Firstname Lastname"). Sandbox is lenient. Validate here so the
# error message is helpful instead of a generic API 400.
if not self.cardholder_name or len(self.cardholder_name.strip().split()) < 2:
raise UserError(_(
"Please enter the cardholder's name as it appears on "
"the card (e.g. \"John Doe\"). Clover requires both "
"first and last name."
))
def _create_payment_transaction(self):
"""Create a payment.transaction linked to the invoice."""

View File

@@ -83,7 +83,8 @@
required="payment_mode == 'card' and state in ('draft', 'error')"
password="True"/>
<field name="cardholder_name"
placeholder="Name on card"/>
placeholder="Firstname Lastname"
required="payment_mode == 'card' and state in ('draft', 'error')"/>
</group>
<group>
<field name="exp_month"