diff --git a/fusion_clover/CLAUDE.md b/fusion_clover/CLAUDE.md new file mode 100644 index 00000000..e8354c3b --- /dev/null +++ b/fusion_clover/CLAUDE.md @@ -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', '', 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 + +``` +. + +payload_json = { + "redirect_to": "https://erp..ca/payment/clover/oauth/callback", + "nonce": "<32 hex chars>", + "iat": , + "customer": "" +} +``` + +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.` (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": ""}` 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": {"": + [{"objectId": "P:/", "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": ""}` 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 `` / `` / `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 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 = "" +$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 diff --git a/fusion_clover/__manifest__.py b/fusion_clover/__manifest__.py index 0d7d5dfa..ceed48dd 100644 --- a/fusion_clover/__manifest__.py +++ b/fusion_clover/__manifest__.py @@ -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', diff --git a/fusion_clover/const.py b/fusion_clover/const.py index fec238cc..ccf6fcd8 100644 --- a/fusion_clover/const.py +++ b/fusion_clover/const.py @@ -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', diff --git a/fusion_clover/controllers/main.py b/fusion_clover/controllers/main.py index 5dd02bee..723b23b9 100644 --- a/fusion_clover/controllers/main.py +++ b/fusion_clover/controllers/main.py @@ -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": ""}`` 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": {"": [{"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:/" (Customer), "O:/" (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.'} diff --git a/fusion_clover/data/payment_provider_data.xml b/fusion_clover/data/payment_provider_data.xml index 7035eca8..194cd965 100644 --- a/fusion_clover/data/payment_provider_data.xml +++ b/fusion_clover/data/payment_provider_data.xml @@ -7,7 +7,8 @@ True disabled - + diff --git a/fusion_clover/models/payment_provider.py b/fusion_clover/models/payment_provider.py index 2928dc8e..c79e5ad7 100644 --- a/fusion_clover/models/payment_provider.py +++ b/fusion_clover/models/payment_provider.py @@ -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: ``.`` + """ + 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( diff --git a/fusion_clover/models/payment_transaction.py b/fusion_clover/models/payment_transaction.py index 90ac1e24..0c0ea594 100644 --- a/fusion_clover/models/payment_transaction.py +++ b/fusion_clover/models/payment_transaction.py @@ -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': , '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': diff --git a/fusion_clover/static/description/fusion_clover.png b/fusion_clover/static/description/fusion_clover.png new file mode 100644 index 00000000..bcc1d87b Binary files /dev/null and b/fusion_clover/static/description/fusion_clover.png differ diff --git a/fusion_clover/static/src/interactions/payment_form.js b/fusion_clover/static/src/interactions/payment_form.js index e608c71a..fd904768 100644 --- a/fusion_clover/static/src/interactions/payment_form.js +++ b/fusion_clover/static/src/interactions/payment_form.js @@ -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 = `
@@ -405,14 +441,12 @@ patch(PaymentForm.prototype, {
`; } - 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, diff --git a/fusion_clover/utils.py b/fusion_clover/utils.py index 1854b56f..92995ec2 100644 --- a/fusion_clover/utils.py +++ b/fusion_clover/utils.py @@ -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": }`` 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} diff --git a/fusion_clover/views/clover_terminal_views.xml b/fusion_clover/views/clover_terminal_views.xml index f416fbb8..9f88fe4d 100644 --- a/fusion_clover/views/clover_terminal_views.xml +++ b/fusion_clover/views/clover_terminal_views.xml @@ -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"/>
diff --git a/fusion_clover/views/payment_clover_templates.xml b/fusion_clover/views/payment_clover_templates.xml index 57a181ae..fa227613 100644 --- a/fusion_clover/views/payment_clover_templates.xml +++ b/fusion_clover/views/payment_clover_templates.xml @@ -1,7 +1,14 @@ - +