# Part of Odoo. See LICENSE file for full copyright and licensing details. 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.""" try: raw_body = request.httprequest.data.decode('utf-8') event = json.loads(raw_body) except (ValueError, UnicodeDecodeError): _logger.warning("Received invalid JSON from Clover webhook") return request.make_json_response({'status': 'error'}, status=400) _logger.info( "Clover webhook notification received:\n%s", pprint.pformat(event), ) try: event_type = event.get('type', '') data = event.get('data', {}) if event_type in ('charge.succeeded', 'charge.captured'): 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', '')) elif event_type == 'payment.created': self._handle_charge_webhook(data, 'succeeded') except ValidationError: _logger.exception("Unable to process Clover webhook; acknowledging to avoid retries") return request.make_json_response({'status': 'ok'}) 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 a merchant authorizes the app, Clover redirects here with an authorization code. We exchange it for an access token and store the merchant_id. """ 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) 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) return request.redirect('/odoo/settings') # === 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, **kwargs): """Process a card payment through 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. :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: return {'error': 'Missing card token. Please try again.'} try: if card_type: surcharge_fee = 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', 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', {}), } 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) return {'error': 'Payment processing failed. Please try again.'}