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

View File

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