changes
This commit is contained in:
626
fusion_clover/CLAUDE.md
Normal file
626
fusion_clover/CLAUDE.md
Normal 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
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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':
|
||||
|
||||
BIN
fusion_clover/static/description/fusion_clover.png
Normal file
BIN
fusion_clover/static/description/fusion_clover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user