changes
This commit is contained in:
@@ -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.'}
|
||||
|
||||
Reference in New Issue
Block a user