# Part of Odoo. See LICENSE file for full copyright and licensing details. import hashlib import hmac import json import logging import pprint from odoo import fields, http from odoo.exceptions import UserError, ValidationError from odoo.http import request from odoo.tools import mute_logger from odoo.addons.fusion_clover import utils as clover_utils _logger = logging.getLogger(__name__) def _detect_card_brand(card_number): """Detect the card brand from the card number using BIN prefixes.""" num = (card_number or '').replace(' ', '') if len(num) < 2: return 'other' if num[:2] in ('34', '37'): return 'amex' if num[0] == '4': return 'visa' prefix2 = int(num[:2]) if 51 <= prefix2 <= 55: return 'mastercard' if len(num) >= 4: prefix4 = int(num[:4]) if 2221 <= prefix4 <= 2720: return 'mastercard' return 'other' class CloverController(http.Controller): _return_url = '/payment/clover/return' _webhook_url = '/payment/clover/webhook' _oauth_callback_url = '/payment/clover/oauth/callback' # === RETURN ROUTE === # @http.route(_return_url, type='http', methods=['GET'], auth='public') def clover_return(self, **data): """Process the return from a Clover payment flow.""" tx_sudo = request.env['payment.transaction'].sudo()._search_by_reference( 'clover', data, ) if tx_sudo and tx_sudo.clover_charge_id: try: provider = tx_sudo.provider_id.sudo() charge_data = provider._clover_make_ecom_request( 'GET', f'v1/charges/{tx_sudo.clover_charge_id}', ) payment_data = { 'reference': tx_sudo.reference, 'clover_charge_id': charge_data.get('id'), 'clover_status': charge_data.get('status', ''), 'source': charge_data.get('source', {}), } tx_sudo._process('clover', payment_data) except ValidationError: _logger.error( "Failed to fetch Clover charge %s on return.", tx_sudo.clover_charge_id, ) with mute_logger('werkzeug'): return request.redirect('/payment/status') # === WEBHOOK ROUTE === # @http.route(_webhook_url, type='http', methods=['POST'], auth='public', csrf=False) def clover_webhook(self): """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: 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: self._dispatch_clover_webhook(event) except ValidationError: _logger.exception("Unable to process Clover webhook; acknowledging to avoid retries") 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') elif event_type == 'charge.voided': self._handle_void_webhook(data) elif event_type in ('refund.created', 'refund.succeeded'): self._handle_refund_webhook(data) elif event_type == 'refund.failed': _logger.warning("Clover refund failed webhook: %s", data.get('id', '')) else: _logger.info("Unhandled Clover webhook event type: %s", event_type) return # --- 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, ) 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.""" charge_id = data.get('id', '') if not charge_id: return payment_data = { 'clover_charge_id': charge_id, 'clover_status': status, 'source': data.get('source', {}), } # Try to find by metadata reference first metadata = data.get('metadata', {}) reference = metadata.get('odoo_reference', '') if reference: payment_data['reference'] = reference tx_sudo = request.env['payment.transaction'].sudo()._search_by_reference( 'clover', payment_data, ) if tx_sudo: tx_sudo._process('clover', payment_data) def _handle_void_webhook(self, data): """Process a void webhook event.""" charge_id = data.get('id', '') if not charge_id: return tx_sudo = request.env['payment.transaction'].sudo().search([ ('clover_charge_id', '=', charge_id), ('provider_code', '=', 'clover'), ('state', '=', 'done'), ], limit=1) if not tx_sudo: return if not tx_sudo.clover_voided: tx_sudo.sudo().write({ 'state': 'cancel', 'clover_voided': True, 'clover_void_date': fields.Datetime.now(), }) _logger.info("Clover void webhook: voided transaction %s", tx_sudo.reference) def _handle_refund_webhook(self, data): """Process a refund webhook event.""" refund_id = data.get('id', '') charge_id = data.get('charge', '') if not charge_id: return source_tx = request.env['payment.transaction'].sudo().search([ ('clover_charge_id', '=', charge_id), ('provider_code', '=', 'clover'), ('state', '=', 'done'), ], limit=1) if not source_tx: return existing_refund = source_tx.child_transaction_ids.filtered( lambda t: t.provider_reference == refund_id ) if existing_refund: return refund_amount_minor = data.get('amount', 0) refund_amount = clover_utils.parse_clover_amount( refund_amount_minor, source_tx.currency_id, ) refund_tx = source_tx._create_child_transaction( refund_amount, is_refund=True, ) payment_data = { 'reference': refund_tx.reference, 'clover_charge_id': charge_id, 'clover_refund_id': refund_id, 'clover_status': 'succeeded', } refund_tx._process('clover', payment_data) # === OAUTH CALLBACK ROUTE === # @http.route(_oauth_callback_url, type='http', methods=['GET'], auth='user') def clover_oauth_callback(self, **data): """Handle the OAuth2 authorization callback from Clover. 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', '') state = data.get('state', '') if not code: _logger.warning("Clover OAuth callback missing authorization code") return request.redirect('/odoo/settings') # 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') # 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): """Apply credit card surcharge to the linked invoice if enabled.""" ICP = request.env['ir.config_parameter'].sudo() if ICP.get_param('fusion_clover.surcharge_enabled', 'False') != 'True': return 0.0 if not card_type: card_type = 'other' rate_key = { 'visa': 'fusion_clover.surcharge_visa_rate', 'mastercard': 'fusion_clover.surcharge_mastercard_rate', 'amex': 'fusion_clover.surcharge_amex_rate', 'debit': 'fusion_clover.surcharge_debit_rate', }.get(card_type, 'fusion_clover.surcharge_other_rate') rate = float(ICP.get_param(rate_key, '0') or 0) if rate <= 0: return 0.0 invoices = tx_sudo.invoice_ids if not invoices: base_amount = tx_sudo.amount else: base_amount = sum(invoices.mapped('amount_residual')) fee_amount = round(base_amount * rate / 100.0, 2) if fee_amount <= 0: return 0.0 product_id = int(ICP.get_param('fusion_clover.surcharge_product_id', '0') or 0) product = request.env['product.product'].sudo().browse(product_id).exists() if not product: product = request.env.ref( 'fusion_clover.product_cc_processing_fee', raise_if_not_found=False, ) if not product: _logger.warning("Surcharge product not configured; skipping surcharge") return 0.0 for invoice in invoices.sudo(): was_posted = invoice.state == 'posted' if was_posted: invoice.button_draft() description = "Credit Card Processing Fee (%.2f%% surcharge)" % rate invoice.write({ 'invoice_line_ids': [(0, 0, { 'product_id': product.id, 'name': description, 'quantity': 1, 'price_unit': fee_amount, 'tax_ids': [(5, 0, 0)], })], }) if was_posted: invoice.action_post() new_amount = tx_sudo.amount + fee_amount tx_sudo.write({'amount': new_amount}) return fee_amount # === TERMINAL ROUTES === # @http.route('/payment/clover/terminals', type='jsonrpc', auth='public') def clover_list_terminals(self, provider_id=None, **kwargs): """Return a list of active terminals for the given Clover provider.""" if not provider_id: return [] terminals = request.env['clover.terminal'].sudo().search([ ('provider_id', '=', int(provider_id)), ('active', '=', True), ]) return [ { 'id': t.id, 'name': t.name, 'serial': t.serial_number, 'status': t.status or 'unknown', 'model': t.model_name or '', } for t in terminals ] @http.route('/payment/clover/send_to_terminal', type='jsonrpc', auth='public') def clover_send_to_terminal(self, reference=None, terminal_id=None, card_type=None, **kwargs): """Send a payment request to a Clover terminal via Cloud Pay Display.""" if not reference or not terminal_id: return {'error': 'Missing reference or terminal ID.'} tx_sudo = request.env['payment.transaction'].sudo().search([ ('reference', '=', reference), ('provider_code', '=', 'clover'), ], limit=1) if not tx_sudo: return {'error': 'Transaction not found.'} terminal = request.env['clover.terminal'].sudo().browse(int(terminal_id)) if not terminal.exists(): return {'error': 'Terminal not found.'} try: if card_type: self._apply_portal_surcharge(tx_sudo, card_type) provider = tx_sudo.provider_id.sudo() capture = not provider.capture_manually result = terminal.action_send_payment( amount=tx_sudo.amount, currency=tx_sudo.currency_id, reference=reference, capture=capture, ) return {'success': True} except (ValidationError, UserError) as e: return {'error': str(e)} @http.route('/payment/clover/terminal_status', type='jsonrpc', auth='public') def clover_terminal_status(self, reference=None, terminal_id=None, **kwargs): """Poll for the status of a terminal payment.""" if not reference or not terminal_id: return {'status': 'error', 'message': 'Missing reference or terminal ID.'} terminal = request.env['clover.terminal'].sudo().browse(int(terminal_id)) if not terminal.exists(): return {'status': 'error', 'message': 'Terminal not found.'} result = terminal.action_check_payment_status(reference) status = result.get('status', 'pending') # If payment completed, process the transaction if status in ('CLOSED', 'AUTH', 'AUTHORIZED', 'CAPTURED'): tx_sudo = request.env['payment.transaction'].sudo().search([ ('reference', '=', reference), ('provider_code', '=', 'clover'), ], limit=1) if tx_sudo and tx_sudo.state == 'draft': payment_id = result.get('payment_id', '') card_txn = result.get('card_transaction', {}) tx_sudo.write({ 'clover_charge_id': payment_id or tx_sudo.clover_charge_id, 'provider_reference': payment_id or tx_sudo.provider_reference, }) payment_data = { 'reference': reference, 'clover_charge_id': payment_id, 'clover_status': 'succeeded', 'source': { 'brand': card_txn.get('cardType', ''), 'last4': card_txn.get('last4', ''), }, } tx_sudo._process('clover', payment_data) return result @http.route('/payment/clover/terminal/callback', type='http', methods=['POST'], auth='public', csrf=False) def clover_terminal_callback(self, **data): """Handle callback from terminal payment completion (if configured).""" _logger.info("Clover terminal callback received: %s", data) return request.make_json_response({'status': 'ok'}) # === JSON-RPC ROUTES (called from frontend JS) === # @http.route('/payment/clover/process_card', type='jsonrpc', auth='public') def clover_process_card(self, reference=None, card_token=None, card_type=None, save_token=False, **kwargs): """Process a card payment through the Clover Ecommerce API. 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 """ if not reference: return {'error': 'Missing payment reference.'} tx_sudo = request.env['payment.transaction'].sudo().search([ ('reference', '=', reference), ('provider_code', '=', 'clover'), ], limit=1) if not tx_sudo: return {'error': 'Transaction not found.'} 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: self._apply_portal_surcharge(tx_sudo, card_type) provider = tx_sudo.provider_id.sudo() capture = not provider.capture_manually result = provider._clover_create_charge( source_token=card_token, amount=tx_sudo.amount, currency=tx_sudo.currency_id, capture=capture, description=reference, ecomind='ecom', receipt_email=tx_sudo.partner_id.email or '', metadata={'odoo_reference': reference}, ) charge_id = result.get('id', '') status = result.get('status', '') tx_sudo.write({ 'clover_charge_id': charge_id, 'provider_reference': charge_id, }) payment_data = { 'reference': reference, 'clover_charge_id': charge_id, '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.exception("Card payment processing failed: %s", e) return {'error': 'Payment processing failed. Please try again.'}