diff --git a/fusion_clover/__init__.py b/fusion_clover/__init__.py new file mode 100644 index 00000000..4d267689 --- /dev/null +++ b/fusion_clover/__init__.py @@ -0,0 +1,17 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models +from . import wizard + + +def post_init_hook(env): + provider = env.ref('fusion_clover.payment_provider_clover', raise_if_not_found=False) + if provider: + provider._setup_provider('clover') + + +def uninstall_hook(env): + provider = env.ref('fusion_clover.payment_provider_clover', raise_if_not_found=False) + if provider: + provider.write({'state': 'disabled', 'is_published': False}) diff --git a/fusion_clover/__manifest__.py b/fusion_clover/__manifest__.py new file mode 100644 index 00000000..0d7d5dfa --- /dev/null +++ b/fusion_clover/__manifest__.py @@ -0,0 +1,42 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Payment Provider: Clover', + 'version': '19.0.1.0.0', + 'category': 'Accounting/Payment Providers', + 'sequence': 365, + 'summary': "Clover payment processing for ecommerce, terminal, and manual card payments.", + 'description': " ", + 'depends': ['payment', 'account_payment', 'sale'], + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + + 'report/clover_receipt_report.xml', + 'report/clover_receipt_templates.xml', + + 'data/clover_surcharge_product.xml', + + 'views/payment_provider_views.xml', + 'views/payment_transaction_views.xml', + 'views/payment_clover_templates.xml', + 'views/account_move_views.xml', + 'views/sale_order_views.xml', + 'views/res_config_settings_views.xml', + 'views/clover_terminal_views.xml', + 'wizard/clover_payment_wizard_views.xml', + 'wizard/clover_refund_wizard_views.xml', + + 'data/payment_provider_data.xml', + 'data/clover_receipt_email_template.xml', + ], + 'post_init_hook': 'post_init_hook', + 'uninstall_hook': 'uninstall_hook', + 'assets': { + 'web.assets_frontend': [ + 'fusion_clover/static/src/interactions/**/*', + ], + }, + 'author': 'Fusion Apps', + 'license': 'LGPL-3', +} diff --git a/fusion_clover/__pycache__/__init__.cpython-312.pyc b/fusion_clover/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..fa28085e Binary files /dev/null and b/fusion_clover/__pycache__/__init__.cpython-312.pyc differ diff --git a/fusion_clover/__pycache__/__manifest__.cpython-312.pyc b/fusion_clover/__pycache__/__manifest__.cpython-312.pyc new file mode 100644 index 00000000..129cd229 Binary files /dev/null and b/fusion_clover/__pycache__/__manifest__.cpython-312.pyc differ diff --git a/fusion_clover/__pycache__/const.cpython-312.pyc b/fusion_clover/__pycache__/const.cpython-312.pyc new file mode 100644 index 00000000..5a6cc22c Binary files /dev/null and b/fusion_clover/__pycache__/const.cpython-312.pyc differ diff --git a/fusion_clover/__pycache__/utils.cpython-312.pyc b/fusion_clover/__pycache__/utils.cpython-312.pyc new file mode 100644 index 00000000..5ff5280f Binary files /dev/null and b/fusion_clover/__pycache__/utils.cpython-312.pyc differ diff --git a/fusion_clover/const.py b/fusion_clover/const.py new file mode 100644 index 00000000..fec238cc --- /dev/null +++ b/fusion_clover/const.py @@ -0,0 +1,85 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +# Clover Ecommerce API (charges, refunds, tokenization) +ECOM_BASE_URL = 'https://scl.clover.com' +ECOM_BASE_URL_TEST = 'https://scl-sandbox.dev.clover.com' + +# Clover Platform API (merchants, orders, etc.) +API_BASE_URL = 'https://api.clover.com' +API_BASE_URL_TEST = 'https://apisandbox.dev.clover.com' + +# Clover Tokenization Service +TOKEN_BASE_URL = 'https://token.clover.com' +TOKEN_BASE_URL_TEST = 'https://token-sandbox.dev.clover.com' + +# Clover Card Present / REST Pay Display API (Cloud connection) +# Used for sending payment requests to Clover terminals via cloud. +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' + +DEFAULT_PAYMENT_METHOD_CODES = { + 'card', + 'visa', + 'mastercard', + 'amex', + 'discover', +} + +# Mapping of Clover charge statuses to Odoo payment transaction states. +STATUS_MAPPING = { + 'authorized': ('pending',), + 'done': ('succeeded', 'paid', 'captured'), + 'cancel': ('canceled', 'voided'), + 'error': ('failed',), + 'refund': ('refunded',), +} + +# Card brand mapping from Clover scheme to Odoo payment method codes +CARD_BRAND_MAPPING = { + 'VISA': 'visa', + 'MC': 'mastercard', + 'MASTERCARD': 'mastercard', + 'AMEX': 'amex', + 'AMERICAN_EXPRESS': 'amex', + 'DISCOVER': 'discover', + 'DINERS_CLUB': 'diners_club', + 'JCB': 'jcb', +} + +# Clover amounts are in cents (minor currency units) +CURRENCY_DECIMALS = { + 'JPY': 0, + 'KRW': 0, +} + +# Clover Platform API v3 — transaction statuses that indicate a void +VOIDED_STATUSES = {'VOIDED', 'VOID'} + +# Referenced refund age limit (days). Clover does NOT impose a hard limit, +# but card networks generally restrict refund-to-original-card beyond ~180 days. +REFERENCED_REFUND_LIMIT_DAYS = 180 + +# Handled webhook event types +HANDLED_WEBHOOK_EVENTS = { + 'charge.succeeded', + 'charge.failed', + 'charge.captured', + 'charge.voided', + 'refund.created', + 'refund.succeeded', + 'refund.failed', + 'payment.created', +} + +# Sensitive keys that should be masked in logs +SENSITIVE_KEYS = { + 'clover_api_key', + 'clover_secret', + 'access_token', +} diff --git a/fusion_clover/controllers/__init__.py b/fusion_clover/controllers/__init__.py new file mode 100644 index 00000000..8739d256 --- /dev/null +++ b/fusion_clover/controllers/__init__.py @@ -0,0 +1,4 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main +from . import portal diff --git a/fusion_clover/controllers/__pycache__/__init__.cpython-312.pyc b/fusion_clover/controllers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..0808c947 Binary files /dev/null and b/fusion_clover/controllers/__pycache__/__init__.cpython-312.pyc differ diff --git a/fusion_clover/controllers/__pycache__/main.cpython-312.pyc b/fusion_clover/controllers/__pycache__/main.cpython-312.pyc new file mode 100644 index 00000000..07a222fc Binary files /dev/null and b/fusion_clover/controllers/__pycache__/main.cpython-312.pyc differ diff --git a/fusion_clover/controllers/__pycache__/portal.cpython-312.pyc b/fusion_clover/controllers/__pycache__/portal.cpython-312.pyc new file mode 100644 index 00000000..7186f3a6 Binary files /dev/null and b/fusion_clover/controllers/__pycache__/portal.cpython-312.pyc differ diff --git a/fusion_clover/controllers/main.py b/fusion_clover/controllers/main.py new file mode 100644 index 00000000..5dd02bee --- /dev/null +++ b/fusion_clover/controllers/main.py @@ -0,0 +1,497 @@ +# 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.'} diff --git a/fusion_clover/controllers/portal.py b/fusion_clover/controllers/portal.py new file mode 100644 index 00000000..bb01f5cf --- /dev/null +++ b/fusion_clover/controllers/portal.py @@ -0,0 +1,51 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http +from odoo.http import request + +from odoo.addons.sale.controllers.portal import CustomerPortal + + +class CloverCustomerPortal(CustomerPortal): + + @http.route() + def portal_order_page( + self, + order_id, + report_type=None, + access_token=None, + message=False, + download=False, + payment_amount=None, + amount_selection=None, + **kw + ): + """Auto-inject payment_amount for confirmed orders with outstanding balance.""" + if payment_amount is None: + try: + order_sudo = self._document_check_access( + 'sale.order', order_id, access_token=access_token, + ) + except Exception: + order_sudo = None + + if order_sudo: + is_rental = getattr(order_sudo, 'is_rental_order', False) + if ( + order_sudo.state == 'sale' + and not is_rental + and order_sudo.amount_total > 0 + and order_sudo.amount_paid < order_sudo.amount_total + ): + payment_amount = order_sudo.amount_total - order_sudo.amount_paid + + return super().portal_order_page( + order_id, + report_type=report_type, + access_token=access_token, + message=message, + download=download, + payment_amount=payment_amount, + amount_selection=amount_selection, + **kw, + ) diff --git a/fusion_clover/data/clover_receipt_email_template.xml b/fusion_clover/data/clover_receipt_email_template.xml new file mode 100644 index 00000000..482d7e38 --- /dev/null +++ b/fusion_clover/data/clover_receipt_email_template.xml @@ -0,0 +1,97 @@ + + + + + + Clover: Payment/Refund Receipt + + {{ object.company_id.name }} - {{ 'Refund Receipt' if (object.operation == 'refund' or object.amount < 0) else 'Payment Receipt' }} {{ object.reference or 'n/a' }} + {{ (object.company_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.email }} + + + +
+
+
+

+ +

+

+ Refund Receipt + Payment Receipt +

+

+ + Your refund for has been processed. + + + Your payment for has been processed successfully. + +

+ + + + + + + + + + + + + + + + + + + + + + + +
Transaction Details
Type + Refund + Payment +
Reference
Date
Status + Refunded + Confirmed +
Amount + - +
+ +
+

Attached: Transaction Receipt (PDF)

+
+ +
+

+ + The refund will appear on your card within 3-5 business days. If you have any questions, please do not hesitate to contact us. + + + Thank you for your payment. If you have any questions about this transaction, please do not hesitate to contact us. + +

+
+ + +

+ + | + +

+
+
+
+]]>
+ {{ object.partner_id.lang }} + +
+ +
+
diff --git a/fusion_clover/data/clover_surcharge_product.xml b/fusion_clover/data/clover_surcharge_product.xml new file mode 100644 index 00000000..b66ba233 --- /dev/null +++ b/fusion_clover/data/clover_surcharge_product.xml @@ -0,0 +1,18 @@ + + + + + + CREDIT CARD PROCESSING FEE + CLOVER_CC_FEE + service + 0.0 + + + + + Credit card processing surcharge + + + + diff --git a/fusion_clover/data/payment_provider_data.xml b/fusion_clover/data/payment_provider_data.xml new file mode 100644 index 00000000..7035eca8 --- /dev/null +++ b/fusion_clover/data/payment_provider_data.xml @@ -0,0 +1,13 @@ + + + + + Clover + clover + + True + disabled + + + + diff --git a/fusion_clover/models/__init__.py b/fusion_clover/models/__init__.py new file mode 100644 index 00000000..2737af54 --- /dev/null +++ b/fusion_clover/models/__init__.py @@ -0,0 +1,9 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_move +from . import clover_terminal +from . import payment_provider +from . import payment_token +from . import payment_transaction +from . import res_config_settings +from . import sale_order diff --git a/fusion_clover/models/__pycache__/__init__.cpython-312.pyc b/fusion_clover/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..20ec0e69 Binary files /dev/null and b/fusion_clover/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/fusion_clover/models/__pycache__/account_move.cpython-312.pyc b/fusion_clover/models/__pycache__/account_move.cpython-312.pyc new file mode 100644 index 00000000..10f741d5 Binary files /dev/null and b/fusion_clover/models/__pycache__/account_move.cpython-312.pyc differ diff --git a/fusion_clover/models/__pycache__/clover_terminal.cpython-312.pyc b/fusion_clover/models/__pycache__/clover_terminal.cpython-312.pyc new file mode 100644 index 00000000..c2fb6364 Binary files /dev/null and b/fusion_clover/models/__pycache__/clover_terminal.cpython-312.pyc differ diff --git a/fusion_clover/models/__pycache__/payment_provider.cpython-312.pyc b/fusion_clover/models/__pycache__/payment_provider.cpython-312.pyc new file mode 100644 index 00000000..8ebf6bac Binary files /dev/null and b/fusion_clover/models/__pycache__/payment_provider.cpython-312.pyc differ diff --git a/fusion_clover/models/__pycache__/payment_token.cpython-312.pyc b/fusion_clover/models/__pycache__/payment_token.cpython-312.pyc new file mode 100644 index 00000000..d36b2e56 Binary files /dev/null and b/fusion_clover/models/__pycache__/payment_token.cpython-312.pyc differ diff --git a/fusion_clover/models/__pycache__/payment_transaction.cpython-312.pyc b/fusion_clover/models/__pycache__/payment_transaction.cpython-312.pyc new file mode 100644 index 00000000..6a32fd07 Binary files /dev/null and b/fusion_clover/models/__pycache__/payment_transaction.cpython-312.pyc differ diff --git a/fusion_clover/models/__pycache__/res_config_settings.cpython-312.pyc b/fusion_clover/models/__pycache__/res_config_settings.cpython-312.pyc new file mode 100644 index 00000000..6d6d8e40 Binary files /dev/null and b/fusion_clover/models/__pycache__/res_config_settings.cpython-312.pyc differ diff --git a/fusion_clover/models/__pycache__/sale_order.cpython-312.pyc b/fusion_clover/models/__pycache__/sale_order.cpython-312.pyc new file mode 100644 index 00000000..4edb7165 Binary files /dev/null and b/fusion_clover/models/__pycache__/sale_order.cpython-312.pyc differ diff --git a/fusion_clover/models/account_move.py b/fusion_clover/models/account_move.py new file mode 100644 index 00000000..1ad0b15a --- /dev/null +++ b/fusion_clover/models/account_move.py @@ -0,0 +1,200 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class AccountMove(models.Model): + _inherit = 'account.move' + + clover_refunded = fields.Boolean( + string="Refunded via Clover", + readonly=True, + copy=False, + default=False, + ) + clover_refund_count = fields.Integer( + string="Clover Refund Count", + compute='_compute_clover_refund_count', + ) + has_clover_receipt = fields.Boolean( + string="Has Clover Receipt", + compute='_compute_has_clover_receipt', + ) + clover_provider_enabled = fields.Boolean( + string="Clover Provider Enabled", + compute='_compute_clover_provider_enabled', + ) + + @api.depends('reversal_move_ids') + def _compute_clover_refund_count(self): + for move in self: + if move.move_type == 'out_invoice': + move.clover_refund_count = len(move.reversal_move_ids.filtered( + lambda r: r.clover_refunded + )) + else: + move.clover_refund_count = 0 + + def _compute_has_clover_receipt(self): + for move in self: + move.has_clover_receipt = bool(move._get_clover_transaction_for_receipt()) + + def _compute_clover_provider_enabled(self): + provider = self.env['payment.provider'].sudo().search([ + ('code', '=', 'clover'), + ('state', 'in', ('enabled', 'test')), + ], limit=1) + enabled = bool(provider) + for move in self: + move.clover_provider_enabled = enabled + + def action_view_clover_refunds(self): + """Open the credit notes linked to this invoice that were refunded via Clover.""" + self.ensure_one() + refund_moves = self.reversal_move_ids.filtered(lambda r: r.clover_refunded) + action = { + 'name': _("Clover Refunds"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.move', + 'view_mode': 'list,form', + 'domain': [('id', 'in', refund_moves.ids)], + 'context': {'default_move_type': 'out_refund'}, + } + if len(refund_moves) == 1: + action['view_mode'] = 'form' + action['res_id'] = refund_moves.id + return action + + def _get_clover_transaction_for_receipt(self): + """Find the Clover transaction linked to this invoice or credit note.""" + self.ensure_one() + domain = [ + ('provider_id.code', '=', 'clover'), + ('clover_charge_id', '!=', False), + ('state', '=', 'done'), + ] + if self.move_type == 'out_invoice': + domain.append(('invoice_ids', 'in', self.ids)) + elif self.move_type == 'out_refund': + domain += [ + ('operation', '=', 'refund'), + ('invoice_ids', 'in', self.ids), + ] + else: + return self.env['payment.transaction'] + + return self.env['payment.transaction'].sudo().search( + domain, order='id desc', limit=1, + ) + + def action_resend_clover_receipt(self): + """Resend the Clover payment/refund receipt email to the customer.""" + self.ensure_one() + tx = self._get_clover_transaction_for_receipt() + if not tx: + raise UserError(_( + "No completed Clover transaction found for this document." + )) + + template = self.env.ref( + 'fusion_clover.mail_template_clover_receipt', + raise_if_not_found=False, + ) + if not template: + raise UserError(_("Receipt email template not found.")) + + report = self.env.ref( + 'fusion_clover.action_report_clover_receipt', + raise_if_not_found=False, + ) + attachment_ids = [] + if report: + pdf_content, _content_type = report.sudo()._render_qweb_pdf( + report_ref='fusion_clover.action_report_clover_receipt', + res_ids=tx.ids, + ) + prefix = "Refund_Receipt" if self.move_type == 'out_refund' else "Payment_Receipt" + filename = f"{prefix}_{tx.reference}.pdf" + att = self.env['ir.attachment'].create({ + 'name': filename, + 'type': 'binary', + 'datas': base64.b64encode(pdf_content), + 'res_model': self._name, + 'res_id': self.id, + 'mimetype': 'application/pdf', + }) + attachment_ids = [att.id] + + template.send_mail(tx.id, force_send=True) + + is_refund = self.move_type == 'out_refund' + label = _("Refund") if is_refund else _("Payment") + self.message_post( + body=_( + "%(label)s receipt resent to %(email)s.", + label=label, + email=tx.partner_id.email, + ), + message_type='notification', + subtype_xmlid='mail.mt_note', + attachment_ids=attachment_ids, + ) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _("Receipt Sent"), + 'message': _("The receipt has been sent to %s.", + tx.partner_id.email), + 'type': 'success', + 'sticky': False, + }, + } + + def action_open_clover_payment_wizard(self): + """Open the Clover payment collection wizard for this invoice.""" + self.ensure_one() + return { + 'name': _("Collect Clover Payment"), + 'type': 'ir.actions.act_window', + 'res_model': 'clover.payment.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'active_model': 'account.move', + 'active_id': self.id, + }, + } + + def action_open_clover_refund_wizard(self): + """Open the Clover refund wizard for this credit note.""" + self.ensure_one() + return { + 'name': _("Refund via Clover"), + 'type': 'ir.actions.act_window', + 'res_model': 'clover.refund.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'active_model': 'account.move', + 'active_id': self.id, + }, + } + + def _get_original_clover_transaction(self): + """Find the Clover payment transaction from the reversed invoice.""" + self.ensure_one() + origin_invoice = self.reversed_entry_id + if not origin_invoice: + return self.env['payment.transaction'] + + return self.env['payment.transaction'].sudo().search([ + ('invoice_ids', 'in', origin_invoice.ids), + ('state', '=', 'done'), + ('provider_id.code', '=', 'clover'), + ('clover_charge_id', '!=', False), + ], order='id desc', limit=1) diff --git a/fusion_clover/models/clover_terminal.py b/fusion_clover/models/clover_terminal.py new file mode 100644 index 00000000..a78b0948 --- /dev/null +++ b/fusion_clover/models/clover_terminal.py @@ -0,0 +1,282 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +from odoo.addons.fusion_clover import utils as clover_utils + +_logger = logging.getLogger(__name__) + + +class CloverTerminal(models.Model): + _name = 'clover.terminal' + _description = 'Clover Terminal Device' + _order = 'name' + + name = fields.Char( + string="Terminal Name", + required=True, + help="A friendly name for this terminal. You can rename it to " + "identify the location (e.g. 'Front Desk', 'Back Office').", + ) + clover_device_name = fields.Char( + string="Clover Device Name", + readonly=True, + help="The original device name from Clover (read-only).", + ) + serial_number = fields.Char( + string="Serial Number", + help="The Clover device serial number. Used as X-Clover-Device-Id header.", + required=True, + copy=False, + ) + device_id = fields.Char( + string="Device ID", + help="The Clover device UUID from the Platform API.", + copy=False, + ) + provider_id = fields.Many2one( + 'payment.provider', + string="Payment Provider", + required=True, + ondelete='cascade', + domain="[('code', '=', 'clover')]", + ) + model_name = fields.Char( + string="Device Model", + readonly=True, + ) + status = fields.Selection( + selection=[ + ('online', "Online"), + ('offline', "Offline"), + ('unknown', "Unknown"), + ], + string="Status", + default='unknown', + readonly=True, + ) + last_seen = fields.Datetime( + string="Last Seen", + readonly=True, + ) + active = fields.Boolean( + default=True, + ) + + _unique_serial_provider = models.Constraint( + 'UNIQUE(serial_number, provider_id)', + 'A terminal with this serial number already exists for this provider.', + ) + + # === BUSINESS METHODS === # + + def _get_provider_sudo(self): + return self.provider_id.sudo() + + def action_refresh_status(self): + """Check terminal status via the Clover Platform API. + + First tries the Cloud Pay Display ping (POST /connect/v1/device/ping). + If that fails (e.g. REST Pay Display not configured), falls back to + the Platform API device endpoint (GET /v3/merchants/{mId}/devices/{deviceId}). + """ + self.ensure_one() + provider = self._get_provider_sudo() + + # --- Attempt 1: Cloud Pay Display ping --- + try: + provider._clover_terminal_request( + 'POST', 'device/ping', + serial_number=self.serial_number, + ) + self.write({ + 'status': 'online', + 'last_seen': fields.Datetime.now(), + }) + return provider._clover_notification( + _("Terminal '%(name)s' is online.", name=self.name), + 'success', + ) + except (ValidationError, UserError): + _logger.debug( + "Cloud ping failed for %s, trying Platform API.", + self.serial_number, + ) + + # --- Attempt 2: Platform API device lookup --- + if not self.device_id: + self.status = 'unknown' + return provider._clover_notification( + _("Could not reach terminal '%(name)s'. " + "Cloud Pay Display may not be configured for this merchant.", + name=self.name), + 'warning', + ) + + try: + result = provider._clover_make_platform_request( + 'GET', f'devices/{self.device_id}', + ) + # Clover Platform API doesn't return real-time online/offline, + # but a successful response means the device is registered. + self.write({ + 'status': 'online', + 'last_seen': fields.Datetime.now(), + }) + return provider._clover_notification( + _("Terminal '%(name)s' is registered and active on Clover.", + name=self.name), + 'success', + ) + except (ValidationError, UserError) as e: + self.status = 'offline' + return provider._clover_notification( + _("Could not reach terminal '%(name)s': %(error)s", + name=self.name, error=str(e)), + 'danger', + ) + + def action_send_payment(self, amount, currency, reference, capture=True): + """Send a payment request to the Clover terminal via Cloud REST Pay API. + + :param float amount: The payment amount in major currency units. + :param recordset currency: The currency record. + :param str reference: The Odoo payment reference / externalPaymentId. + :param bool capture: Whether to capture immediately (sale) or pre-auth. + :return: The terminal payment response. + :rtype: dict + :raises UserError: If the terminal is offline. + """ + self.ensure_one() + + if self.status == 'offline': + raise UserError( + _("Terminal '%(name)s' appears to be offline. " + "Please check the device and try again.", + name=self.name) + ) + + minor_amount = clover_utils.format_clover_amount(amount, currency) + + payload = { + 'amount': minor_amount, + 'externalPaymentId': reference, + 'capture': capture, + } + + provider = self._get_provider_sudo() + result = provider._clover_terminal_request( + 'POST', 'payments', + serial_number=self.serial_number, + payload=payload, + ) + + _logger.info( + "Payment request sent to terminal %s for %s %s (ref: %s)", + self.serial_number, amount, currency.name, reference, + ) + + return result + + def action_send_refund(self, payment_id, amount=None): + """Send a refund request to the terminal. + + :param str payment_id: The Clover payment UUID to refund. + :param int amount: Optional partial refund amount in cents. + :return: The terminal refund response. + :rtype: dict + """ + self.ensure_one() + + payload = {} + if amount: + payload['amount'] = amount + else: + payload['fullRefund'] = True + + provider = self._get_provider_sudo() + return provider._clover_terminal_request( + 'POST', f'payments/{payment_id}/refunds', + serial_number=self.serial_number, + payload=payload, + ) + + def action_check_payment_status(self, external_payment_id): + """Check the status of a terminal payment by externalPaymentId. + + :param str external_payment_id: The externalPaymentId sent with the payment. + :return: Dict with status and payment data. + :rtype: dict + """ + self.ensure_one() + + provider = self._get_provider_sudo() + try: + result = provider._clover_terminal_request( + 'GET', f'payments?externalPaymentId={external_payment_id}', + serial_number=self.serial_number, + ) + + payment = result.get('payment', {}) + if not payment: + return {'status': 'pending', 'message': 'Waiting for terminal response...'} + + clover_result = payment.get('result', '') + card_txn = payment.get('cardTransaction', {}) + state = card_txn.get('state', '') + + if clover_result == 'SUCCESS': + return { + 'status': state or 'CLOSED', + 'payment_id': payment.get('id', ''), + 'card_transaction': card_txn, + 'amount': payment.get('amount', 0), + 'result': clover_result, + } + + if clover_result in ('FAIL', 'DECLINED'): + return { + 'status': 'DECLINED', + 'message': payment.get('failureMessage', 'Payment declined'), + 'result': clover_result, + } + + return { + 'status': 'pending', + 'message': f'Status: {clover_result or "processing"}', + 'result': clover_result, + } + + except (ValidationError, UserError): + return {'status': 'error', 'message': 'Failed to check payment status.'} + + def action_display_welcome(self): + """Reset the terminal to the welcome screen.""" + self.ensure_one() + provider = self._get_provider_sudo() + try: + provider._clover_terminal_request( + 'POST', 'device/welcome', + serial_number=self.serial_number, + ) + return provider._clover_notification( + _("Welcome screen sent to '%(name)s'.", name=self.name), + 'success', + ) + except (ValidationError, UserError) as e: + _logger.warning("Failed to display welcome on terminal %s: %s", + self.serial_number, e) + return provider._clover_notification( + _("Could not send welcome screen to '%(name)s': %(error)s", + name=self.name, error=str(e)), + 'danger', + ) + + def _get_terminal_callback_url(self): + """Build the callback URL for terminal payment completion.""" + base_url = self._get_provider_sudo().get_base_url() + return f"{base_url}/payment/clover/terminal/callback" diff --git a/fusion_clover/models/payment_provider.py b/fusion_clover/models/payment_provider.py new file mode 100644 index 00000000..2928dc8e --- /dev/null +++ b/fusion_clover/models/payment_provider.py @@ -0,0 +1,608 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +import logging + +import requests + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +from odoo.addons.fusion_clover import const +from odoo.addons.fusion_clover import utils as clover_utils + +_logger = logging.getLogger(__name__) + + +class PaymentProvider(models.Model): + _inherit = 'payment.provider' + + code = fields.Selection( + selection_add=[('clover', "Clover")], + ondelete={'clover': 'set default'}, + ) + clover_api_key = fields.Char( + string="Ecommerce Private Token", + help="The private token from Clover's Ecommerce API Tokens page. " + "Used for online charges and refunds (scl.clover.com).", + required_if_provider='clover', + copy=False, + groups='base.group_system', + ) + clover_merchant_id = fields.Char( + string="Merchant ID", + help="The Clover merchant ID for this business.", + required_if_provider='clover', + copy=False, + ) + clover_rest_api_token = fields.Char( + string="REST API Token", + help="The merchant's REST API token from the Clover dashboard " + "(Setup > API Tokens). Used for Platform API (devices, orders) " + "and terminal payments (Cloud Pay Display). This is different " + "from the Ecommerce API token.", + copy=False, + groups='base.group_system', + ) + clover_app_id = fields.Char( + string="App ID (Client ID)", + help="The Clover App ID (client_id) from the developer dashboard. " + "Used for OAuth2 merchant authorization flow.", + copy=False, + ) + clover_app_secret = fields.Char( + string="App Secret", + help="The Clover App Secret (client_secret) from the developer dashboard.", + copy=False, + groups='base.group_system', + ) + 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_default_terminal_id = fields.Many2one( + 'clover.terminal', + string="Default Terminal", + help="The default Clover terminal used for in-store payment collection. " + "Staff can override this per transaction.", + domain="[('provider_id', '=', id), ('active', '=', True)]", + ) + + # === COMPUTE METHODS === # + + def _compute_feature_support_fields(self): + """Override of `payment` to enable additional features.""" + super()._compute_feature_support_fields() + self.filtered(lambda p: p.code == 'clover').update({ + 'support_manual_capture': 'full_only', + 'support_refund': 'partial', + 'support_tokenization': True, + }) + + # === CRUD METHODS === # + + def _get_default_payment_method_codes(self): + """Override of `payment` to return the default payment method codes.""" + self.ensure_one() + if self.code != 'clover': + return super()._get_default_payment_method_codes() + return const.DEFAULT_PAYMENT_METHOD_CODES + + # === BUSINESS METHODS - API REQUESTS === # + + def _clover_make_ecom_request(self, method, endpoint, payload=None, params=None): + """Make an authenticated API request to the Clover Ecommerce API. + + :param str method: HTTP method (GET, POST, PUT, DELETE). + :param str endpoint: The API endpoint path (e.g., 'v1/charges'). + :param dict payload: The JSON request body (optional). + :param dict params: The query parameters (optional). + :return: The parsed JSON response. + :rtype: dict + :raises ValidationError: If the API request fails. + """ + self.ensure_one() + + is_test = self.state == 'test' + url = clover_utils.build_ecom_url(endpoint, is_test=is_test) + + idempotency_key = clover_utils.generate_idempotency_key() + headers = clover_utils.build_ecom_headers( + self.clover_api_key, idempotency_key=idempotency_key, + ) + + _logger.info( + "Clover Ecom API %s request to %s (idempotency=%s)", + method, url, idempotency_key, + ) + + try: + response = requests.request( + method, + url, + json=payload, + params=params, + headers=headers, + timeout=60, + ) + except requests.exceptions.RequestException as e: + _logger.error("Clover Ecom API request failed: %s", e) + raise ValidationError(_("Communication with Clover failed: %s", e)) + + if response.status_code in (202, 204): + return {} + + try: + result = response.json() + except ValueError: + if response.status_code < 400: + return {} + _logger.error("Clover returned non-JSON response: %s", response.text[:500]) + raise ValidationError(_("Clover returned an invalid response.")) + + if response.status_code >= 400: + error = result.get('error', {}) + error_msg = error.get('message', '') if isinstance(error, dict) else str(error) + error_code = error.get('code', '') if isinstance(error, dict) else '' + _logger.error( + "Clover Ecom API error %s: %s (code=%s)\n" + " URL: %s %s\n Payload: %s\n Response: %s", + response.status_code, error_msg, error_code, + method, url, + json.dumps(payload)[:2000] if payload else 'None', + response.text[:2000], + ) + raise ValidationError( + _("Clover API error (%(code)s): %(msg)s", + code=response.status_code, msg=error_msg or 'Unknown error') + ) + + return result + + def _clover_make_platform_request(self, method, endpoint, payload=None, params=None): + """Make an authenticated request to the Clover Platform API. + + :param str method: HTTP method. + :param str endpoint: The API endpoint path. + :param dict payload: The JSON request body (optional). + :param dict params: The query parameters (optional). + :return: The parsed JSON response. + :rtype: dict + :raises ValidationError: If the API request fails. + """ + self.ensure_one() + + is_test = self.state == 'test' + url = clover_utils.build_platform_url( + 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 + headers = clover_utils.build_ecom_headers(api_token) + + _logger.info("Clover Platform API %s request to %s", method, url) + + try: + response = requests.request( + method, + url, + json=payload, + params=params, + headers=headers, + timeout=60, + ) + except requests.exceptions.RequestException as e: + _logger.error("Clover Platform API request failed: %s", e) + raise ValidationError(_("Communication with Clover failed: %s", e)) + + if response.status_code in (202, 204): + return {} + + try: + result = response.json() + except ValueError: + if response.status_code < 400: + return {} + raise ValidationError(_("Clover returned an invalid response.")) + + if response.status_code >= 400: + error_msg = result.get('message', result.get('error', 'Unknown error')) + raise ValidationError( + _("Clover API error (%(code)s): %(msg)s", + code=response.status_code, msg=error_msg) + ) + + return result + + # === BUSINESS METHODS - CHARGE / TOKENIZE === # + + def _clover_create_charge(self, source_token, amount, currency, + capture=True, description='', ecomind='ecom', + external_reference_id='', receipt_email='', + metadata=None): + """Create a charge via the Clover Ecommerce API. + + :param str source_token: The Clover card token. + :param float amount: The charge amount in major currency units. + :param recordset currency: The currency record. + :param bool capture: Whether to capture immediately. + :param str description: Optional charge description. + :param str ecomind: 'ecom' or 'moto'. + :param str external_reference_id: External reference. + :param str receipt_email: Email for receipt. + :param dict metadata: Optional metadata. + :return: The charge response dict. + :rtype: dict + """ + self.ensure_one() + payload = clover_utils.build_charge_payload( + amount=amount, + currency=currency, + source_token=source_token, + capture=capture, + description=description, + ecomind=ecomind, + external_reference_id=external_reference_id, + receipt_email=receipt_email, + metadata=metadata, + ) + return self._clover_make_ecom_request('POST', 'v1/charges', payload=payload) + + def _clover_capture_charge(self, charge_id, amount=None, currency=None): + """Capture a previously authorized charge. + + :param str charge_id: The Clover charge ID. + :param float amount: Optional capture amount (for partial captures). + :param recordset currency: Optional currency record. + :return: The capture response dict. + :rtype: dict + """ + self.ensure_one() + payload = {} + if amount is not None and currency: + payload['amount'] = clover_utils.format_clover_amount(amount, currency) + return self._clover_make_ecom_request( + 'POST', f'v1/charges/{charge_id}/capture', payload=payload, + ) + + def _clover_create_refund(self, charge_id, amount=None, currency=None, reason=''): + """Create a refund via the Clover Ecommerce API. + + :param str charge_id: The Clover charge ID to refund. + :param float amount: Optional partial refund amount. + :param recordset currency: Optional currency record. + :param str reason: Optional reason. + :return: The refund response dict. + :rtype: dict + """ + self.ensure_one() + payload = clover_utils.build_refund_payload( + charge_id=charge_id, + amount=amount, + currency=currency, + reason=reason, + ) + return self._clover_make_ecom_request('POST', 'v1/refunds', payload=payload) + + # === BUSINESS METHODS - NON-REFERENCED CREDIT === # + + def _clover_create_credit(self, amount, currency, description=''): + """Issue a non-referenced credit (manual refund) via Clover Ecommerce API. + + This creates a credit without referencing an original charge. Useful + when the original transaction is too old for a referenced refund. + + Note: merchants must have manual refunds enabled by Clover support. + + :param float amount: The credit amount in major currency units. + :param recordset currency: The currency record. + :param str description: Optional description. + :return: The credit response dict. + :rtype: dict + """ + self.ensure_one() + minor_amount = clover_utils.format_clover_amount(amount, currency) + payload = { + 'amount': minor_amount, + 'currency': currency.name.lower(), + } + if description: + payload['description'] = description + return self._clover_make_ecom_request('POST', 'v1/credits', payload=payload) + + # === BUSINESS METHODS - VERIFICATION === # + + def _clover_get_charge(self, charge_id): + """Fetch a charge from the Clover Ecommerce API. + + :param str charge_id: The Clover charge ID. + :return: The charge data dict. + :rtype: dict + """ + self.ensure_one() + return self._clover_make_ecom_request('GET', f'v1/charges/{charge_id}') + + def _clover_verify_charge_not_reversed(self, charge_id): + """Check that a charge has not already been fully refunded or voided. + + :param str charge_id: The Clover charge ID. + :return: The charge data dict. + :rtype: dict + :raises UserError: If the charge is already refunded. + """ + self.ensure_one() + charge_data = self._clover_get_charge(charge_id) + status = charge_data.get('status', '') + if status == 'refunded': + raise UserError(_( + "This charge (%(charge_id)s) has already been fully refunded " + "on Clover. A duplicate refund cannot be issued.", + charge_id=charge_id, + )) + return charge_data + + # === BUSINESS METHODS - INLINE FORM === # + + def _clover_get_inline_form_values(self, amount, currency, partner_id, is_validation, + payment_method_sudo=None, **kwargs): + """Return serialized JSON of values needed for the inline payment form. + + :param float amount: The payment amount. + :param recordset currency: The currency of the transaction. + :param int partner_id: The partner ID. + :param bool is_validation: Whether this is a validation operation. + :param recordset payment_method_sudo: The sudoed payment method record. + :return: The JSON-serialized inline form values. + :rtype: str + """ + self.ensure_one() + + partner = self.env['res.partner'].browse(partner_id).exists() + minor_amount = clover_utils.format_clover_amount(amount, currency) if amount else 0 + + inline_form_values = { + 'provider_id': self.id, + 'merchant_id': self.clover_merchant_id, + 'public_key': self.clover_public_key or '', + 'currency_name': currency.name if currency else 'USD', + 'minor_amount': minor_amount, + 'capture_method': 'manual' if self.capture_manually else 'automatic', + 'is_test': self.state == 'test', + 'billing_details': { + 'name': partner.name or '', + 'email': partner.email or '', + 'phone': partner.phone or '', + 'address': { + 'line1': partner.street or '', + 'line2': partner.street2 or '', + 'city': partner.city or '', + 'state': partner.state_id.code or '', + 'country': partner.country_id.code or '', + 'postal_code': partner.zip or '', + }, + }, + 'is_tokenization_required': ( + self.allow_tokenization + and self._is_tokenization_required(**kwargs) + and payment_method_sudo + and payment_method_sudo.support_tokenization + ), + } + + ICP = self.env['ir.config_parameter'].sudo() + surcharge_enabled = ICP.get_param( + 'fusion_clover.surcharge_enabled', 'False', + ) == 'True' + if surcharge_enabled: + inline_form_values['surcharge'] = { + 'enabled': True, + 'visa': float(ICP.get_param('fusion_clover.surcharge_visa_rate', '0') or 0), + 'mastercard': float(ICP.get_param('fusion_clover.surcharge_mastercard_rate', '0') or 0), + 'amex': float(ICP.get_param('fusion_clover.surcharge_amex_rate', '0') or 0), + 'debit': float(ICP.get_param('fusion_clover.surcharge_debit_rate', '0') or 0), + 'other': float(ICP.get_param('fusion_clover.surcharge_other_rate', '0') or 0), + } + + return json.dumps(inline_form_values) + + # === BUSINESS METHODS - TERMINAL (REST Pay Display Cloud API) === # + + def _clover_terminal_request(self, method, endpoint, serial_number=None, + payload=None, params=None): + """Make a request to the Clover REST Pay Display Cloud API. + + Sends commands to Clover terminals through Clover's cloud (Cloud Pay Display). + + :param str method: HTTP method (GET, POST). + :param str endpoint: The API endpoint path (e.g., 'payments', 'device/ping'). + :param str serial_number: The device serial number (X-Clover-Device-Id). + :param dict payload: The JSON request body (optional). + :param dict params: The query parameters (optional). + :return: The parsed JSON response. + :rtype: dict + :raises ValidationError: If the API request fails. + """ + self.ensure_one() + + is_test = self.state == 'test' + base_url = const.CONNECT_BASE_URL_TEST if is_test else const.CONNECT_BASE_URL + url = f"{base_url}/{endpoint}" + + 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', + } + if serial_number: + headers['X-Clover-Device-Id'] = serial_number + + idempotency_key = clover_utils.generate_idempotency_key() + headers['Idempotency-Key'] = idempotency_key + + _logger.info( + "Clover Terminal API %s request to %s (device=%s)", + method, url, serial_number or 'none', + ) + + try: + response = requests.request( + method, + url, + json=payload, + params=params, + headers=headers, + timeout=120, + ) + except requests.exceptions.RequestException as e: + _logger.error("Clover Terminal API request failed: %s", e) + raise ValidationError(_("Communication with Clover terminal failed: %s", e)) + + if response.status_code in (202, 204): + return {} + + try: + result = response.json() + except ValueError: + if response.status_code < 400: + return {} + _logger.error("Clover Terminal returned non-JSON: %s", response.text[:500]) + raise ValidationError(_("Clover terminal returned an invalid response.")) + + if response.status_code >= 400: + error_msg = result.get('message', result.get('error', 'Unknown error')) + _logger.error( + "Clover Terminal API error %s: %s\n URL: %s %s", + response.status_code, error_msg, method, url, + ) + raise ValidationError( + _("Clover terminal error (%(code)s): %(msg)s", + code=response.status_code, msg=error_msg) + ) + + return result + + def _clover_get_merchant_devices(self): + """Fetch all devices provisioned to the merchant from the Platform API. + + :return: List of device dicts with id, serial, name, model. + :rtype: list[dict] + """ + self.ensure_one() + result = self._clover_make_platform_request('GET', 'devices') + elements = result.get('elements', []) + return [ + { + 'id': d.get('id', ''), + 'serial': d.get('serial', ''), + 'name': d.get('name', d.get('productName', 'Clover Device')), + 'model': d.get('model', d.get('productName', '')), + } + for d in elements + if d.get('serial') + ] + + def action_sync_terminals(self): + """Sync terminals from the Clover Platform API.""" + self.ensure_one() + if self.code != 'clover': + return + + try: + devices = self._clover_get_merchant_devices() + except (ValidationError, UserError) as e: + return self._clover_notification( + _("Failed to fetch devices: %(error)s", error=str(e)), + 'danger', + ) + + if not devices: + return self._clover_notification( + _("No devices found for this merchant."), + 'warning', + ) + + Terminal = self.env['clover.terminal'].sudo() + created = 0 + updated = 0 + + for device in devices: + serial = device['serial'] + existing = Terminal.search([ + ('serial_number', '=', serial), + ('provider_id', '=', self.id), + ], limit=1) + + if existing: + # Only update metadata; don't overwrite user-set name + vals = { + 'device_id': device['id'], + 'model_name': device['model'], + 'clover_device_name': device['name'], + } + existing.write(vals) + updated += 1 + else: + Terminal.create({ + 'name': device['name'], + 'clover_device_name': device['name'], + 'serial_number': serial, + 'device_id': device['id'], + 'model_name': device['model'], + 'provider_id': self.id, + }) + created += 1 + + return self._clover_notification( + _("Sync complete: %(created)s created, %(updated)s updated.", + created=created, updated=updated), + 'success', + ) + + # === ACTION METHODS === # + + def action_clover_test_connection(self): + """Test the connection to Clover by fetching merchant info. + + :return: A notification action with the result. + :rtype: dict + """ + self.ensure_one() + + try: + result = self._clover_make_platform_request('GET', '') + merchant_name = result.get('name', 'Unknown') + message = _( + "Connection successful. Merchant: %(name)s (ID: %(mid)s)", + name=merchant_name, + mid=self.clover_merchant_id, + ) + notification_type = 'success' + except (ValidationError, UserError) as e: + message = _("Connection failed: %(error)s", error=str(e)) + notification_type = 'danger' + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': message, + 'sticky': False, + 'type': notification_type, + }, + } + + def _clover_notification(self, message, notification_type='info'): + """Return a display_notification action.""" + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': message, + 'sticky': False, + 'type': notification_type, + }, + } diff --git a/fusion_clover/models/payment_token.py b/fusion_clover/models/payment_token.py new file mode 100644 index 00000000..615cec63 --- /dev/null +++ b/fusion_clover/models/payment_token.py @@ -0,0 +1,18 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + + +class PaymentToken(models.Model): + _inherit = 'payment.token' + + clover_source_token = fields.Char( + string="Clover Source Token", + help="The Clover multi-pay token (source ID) for recurring charges.", + readonly=True, + groups='base.group_system', + ) diff --git a/fusion_clover/models/payment_transaction.py b/fusion_clover/models/payment_transaction.py new file mode 100644 index 00000000..90ac1e24 --- /dev/null +++ b/fusion_clover/models/payment_transaction.py @@ -0,0 +1,663 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 +import json +import logging + +from werkzeug.urls import url_encode + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.urls import urljoin as url_join + +from odoo.addons.fusion_clover import const +from odoo.addons.fusion_clover import utils as clover_utils +from odoo.addons.fusion_clover.controllers.main import CloverController + +_logger = logging.getLogger(__name__) + + +class PaymentTransaction(models.Model): + _inherit = 'payment.transaction' + + clover_charge_id = fields.Char( + string="Clover Charge ID", + readonly=True, + copy=False, + ) + clover_refund_id = fields.Char( + string="Clover Refund ID", + readonly=True, + copy=False, + ) + clover_receipt_data = fields.Text( + string="Clover Receipt Data", + readonly=True, + copy=False, + help="JSON blob with receipt-relevant fields captured at payment time.", + ) + clover_order_id = fields.Char( + string="Clover Order ID", + readonly=True, + copy=False, + ) + clover_voided = fields.Boolean( + string="Voided", + default=False, + copy=False, + ) + clover_void_date = fields.Datetime( + string="Void Date", + readonly=True, + copy=False, + ) + + def _get_provider_sudo(self): + return self.provider_id.sudo() + + # === BUSINESS METHODS - PAYMENT FLOW === # + + def _get_specific_processing_values(self, processing_values): + """Override of payment to return Clover-specific processing values.""" + if self.provider_code != 'clover': + return super()._get_specific_processing_values(processing_values) + + if self.operation == 'online_token': + return {} + + provider = self._get_provider_sudo() + base_url = provider.get_base_url() + return_url = url_join( + base_url, + f'{CloverController._return_url}?{url_encode({"reference": self.reference})}', + ) + + return { + 'return_url': return_url, + 'merchant_id': provider.clover_merchant_id, + 'is_test': provider.state == 'test', + } + + def _send_payment_request(self): + """Override of `payment` to send a payment request to Clover.""" + if self.provider_code != 'clover': + return super()._send_payment_request() + + if self.operation in ('online_token', 'offline'): + return self._clover_process_token_payment() + + @staticmethod + def _detect_card_brand_from_details(payment_details): + """Detect card brand from the payment_details string on a token.""" + details = (payment_details or '').upper() + if 'AMEX' in details or 'AMERICAN_EXPRESS' in details: + return 'amex' + if 'VISA' in details: + return 'visa' + if 'MASTER' in details: + return 'mastercard' + return 'other' + + def _apply_token_surcharge(self): + """Apply surcharge to the linked invoice for token-based payments.""" + ICP = self.env['ir.config_parameter'].sudo() + if ICP.get_param('fusion_clover.surcharge_enabled', 'False') != 'True': + return + + if not self.token_id or not self.invoice_ids: + return + + for inv in self.invoice_ids: + sale_orders = inv.mapped('line_ids.sale_line_ids.order_id') + for so in sale_orders: + if getattr(so, 'is_rental_order', False): + if not getattr(so, 'rental_apply_cc_fee', True): + return + + card_type = self._detect_card_brand_from_details( + self.token_id.payment_details, + ) + 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 + + product_id = int(ICP.get_param('fusion_clover.surcharge_product_id', '0') or 0) + product = self.env['product.product'].sudo().browse(product_id).exists() + if not product: + product = self.env.ref( + 'fusion_clover.product_cc_processing_fee', raise_if_not_found=False, + ) + if not product: + _logger.warning("Surcharge product not configured; skipping token surcharge") + return + + total_fee = 0.0 + for invoice in self.invoice_ids.sudo(): + already_has = invoice.invoice_line_ids.filtered( + lambda l: l.product_id.id == product.id + ) + if already_has: + continue + + fee_amount = round(invoice.amount_residual * rate / 100.0, 2) + if fee_amount <= 0: + continue + + 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() + + total_fee += fee_amount + + if total_fee > 0: + self.amount += total_fee + + def _clover_process_token_payment(self): + """Process a payment using a stored token (card on file).""" + try: + self._apply_token_surcharge() + + provider = self._get_provider_sudo() + capture = not provider.capture_manually + clover_token = self.token_id.clover_source_token + + if not clover_token: + self._set_error(_("No Clover token found for this saved card.")) + return + + result = provider._clover_create_charge( + source_token=clover_token, + amount=self.amount, + currency=self.currency_id, + capture=capture, + description=self.reference, + ecomind='moto', + metadata={'odoo_reference': self.reference}, + ) + + charge_id = result.get('id', '') + status = result.get('status', '') + + self.clover_charge_id = charge_id + self.provider_reference = charge_id + + payment_data = { + 'reference': self.reference, + 'clover_charge_id': charge_id, + 'clover_status': status, + 'source': result.get('source', {}), + } + + if status == 'failed': + outcome = result.get('outcome', {}) + decline_msg = outcome.get('type', status) + self._set_error( + _("Payment %(status)s: %(reason)s", + status=status, reason=decline_msg) + ) + return + + self._process('clover', payment_data) + except ValidationError as e: + self._set_error(str(e)) + + def _send_refund_request(self): + """Override of `payment` to send a refund request to Clover.""" + if self.provider_code != 'clover': + return super()._send_refund_request() + + source_tx = self.source_transaction_id + charge_id = source_tx.clover_charge_id or source_tx.provider_reference + refund_amount = abs(self.amount) + + try: + result = self._get_provider_sudo()._clover_create_refund( + charge_id=charge_id, + amount=refund_amount, + currency=self.currency_id, + reason=f'Refund for {source_tx.reference}', + ) + + refund_id = result.get('id', '') + self.provider_reference = refund_id + self.clover_refund_id = refund_id + + payment_data = { + 'reference': self.reference, + 'clover_charge_id': charge_id, + 'clover_refund_id': refund_id, + 'clover_status': result.get('status', 'succeeded'), + } + self._process('clover', payment_data) + except ValidationError as e: + self._set_error(str(e)) + + def _send_capture_request(self): + """Override of `payment` to send a capture request to Clover.""" + if self.provider_code != 'clover': + return super()._send_capture_request() + + source_tx = self.source_transaction_id + charge_id = source_tx.clover_charge_id or source_tx.provider_reference + + try: + result = self._get_provider_sudo()._clover_capture_charge( + charge_id=charge_id, + amount=self.amount, + currency=self.currency_id, + ) + + payment_data = { + 'reference': self.reference, + 'clover_charge_id': result.get('id', charge_id), + 'clover_status': result.get('status', 'succeeded'), + } + self._process('clover', payment_data) + except ValidationError as e: + self._set_error(str(e)) + + def _send_void_request(self): + """Override of `payment` to send a void (refund full) request to Clover. + + Clover doesn't have a dedicated void endpoint -- a full refund before + settlement acts as a void. + """ + if self.provider_code != 'clover': + return super()._send_void_request() + + source_tx = self.source_transaction_id + charge_id = source_tx.clover_charge_id or source_tx.provider_reference + + try: + result = self._get_provider_sudo()._clover_create_refund( + charge_id=charge_id, + reason=f'Void for {source_tx.reference}', + ) + + payment_data = { + 'reference': self.reference, + 'clover_charge_id': charge_id, + 'clover_refund_id': result.get('id', ''), + 'clover_status': result.get('status', 'succeeded'), + } + self._process('clover', payment_data) + except ValidationError as e: + self._set_error(str(e)) + + # === ACTION METHODS - VOID === # + + def action_clover_void(self): + """Void a confirmed Clover transaction (same-day, before settlement). + + Clover's Ecommerce API treats a full refund on an unsettled charge as a + void. We issue ``POST /v1/refunds`` for the full amount; if the charge + has already settled, the processor will decline the void (the user + should create a credit note and use the refund wizard instead). + """ + self.ensure_one() + if self.provider_code != 'clover': + raise ValidationError(_("This action is only available for Clover transactions.")) + if self.state != 'done': + raise ValidationError(_("Only confirmed transactions can be voided.")) + + charge_id = self.clover_charge_id or self.provider_reference + if not charge_id: + raise ValidationError(_("No Clover charge ID found.")) + + # Guard against double reversal + existing_refund = self.env['payment.transaction'].sudo().search([ + ('source_transaction_id', '=', self.id), + ('operation', '=', 'refund'), + ('state', '=', 'done'), + ], limit=1) + if existing_refund: + raise ValidationError(_( + "This transaction has already been refunded " + "(%(ref)s). Voiding would result in a double reversal.", + ref=existing_refund.reference, + )) + + provider = self._get_provider_sudo() + + # Verify on Clover the charge hasn't already been refunded + try: + charge_data = provider._clover_make_ecom_request( + 'GET', f'v1/charges/{charge_id}', + ) + charge_status = charge_data.get('status', '') + if charge_status == 'refunded': + raise ValidationError(_( + "This charge has already been refunded on Clover. " + "It cannot be voided again." + )) + except ValidationError: + raise + except Exception: + _logger.debug("Could not verify charge %s before void", charge_id) + + # Issue full refund (acts as void before settlement) + try: + result = provider._clover_create_refund( + charge_id=charge_id, + reason=f'Void for {self.reference}', + ) + except ValidationError as e: + error_msg = str(e) + if '400' in error_msg or 'declined' in error_msg.lower(): + raise ValidationError(_( + "Void declined by the payment processor. This usually " + "means the batch has already settled. Settled transactions " + "cannot be voided.\n\n" + "To reverse this payment, create a Credit Note on the " + "invoice and process a refund through the Clover refund " + "wizard." + )) + raise + + _logger.info( + "Clover void response: id=%s, status=%s", + result.get('id', ''), result.get('status', ''), + ) + + # Cancel the Odoo payment + if self.payment_id: + self.payment_id.sudo().action_cancel() + + self.sudo().write({ + 'state': 'cancel', + 'clover_voided': True, + 'clover_void_date': fields.Datetime.now(), + }) + + invoice = self.invoice_ids[:1] + if invoice: + invoice.sudo().message_post( + body=_( + "Payment voided: transaction %(ref)s was voided on Clover " + "(Clover Refund ID: %(refund_id)s).", + ref=self.reference, + refund_id=result.get('id', ''), + ), + ) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'success', + 'message': _("Transaction voided successfully on Clover."), + 'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'}, + }, + } + + # === BUSINESS METHODS - NOTIFICATION PROCESSING === # + + @api.model + def _search_by_reference(self, provider_code, payment_data): + """Override of payment to find the transaction based on Clover data.""" + if provider_code != 'clover': + return super()._search_by_reference(provider_code, payment_data) + + reference = payment_data.get('reference') + if reference: + tx = self.search([ + ('reference', '=', reference), + ('provider_code', '=', 'clover'), + ]) + else: + charge_id = payment_data.get('clover_charge_id') + if charge_id: + tx = self.search([ + ('clover_charge_id', '=', charge_id), + ('provider_code', '=', 'clover'), + ]) + else: + _logger.warning("Received Clover data with no reference or charge ID") + tx = self + + if not tx: + _logger.warning( + "No transaction found matching Clover reference %s", reference, + ) + + return tx + + def _apply_updates(self, payment_data): + """Override of `payment` to update the transaction based on Clover data.""" + if self.provider_code != 'clover': + return super()._apply_updates(payment_data) + + charge_id = payment_data.get('clover_charge_id') + if charge_id: + self.provider_reference = charge_id + self.clover_charge_id = charge_id + + refund_id = payment_data.get('clover_refund_id') + if refund_id: + self.clover_refund_id = refund_id + + source = payment_data.get('source', {}) + if source: + card_details = clover_utils.extract_card_details(source) + if card_details.get('brand'): + payment_method = self.env['payment.method']._get_from_code( + card_details['brand'], + mapping=const.CARD_BRAND_MAPPING, + ) + if payment_method: + self.payment_method_id = payment_method + + status = payment_data.get('clover_status', '') + if not status: + self._set_error(_("Received data with missing transaction status.")) + return + + odoo_state = clover_utils.get_clover_status(status) + + if odoo_state == 'authorized': + self._set_authorized() + elif odoo_state == 'done': + self._set_done() + self._post_process() + self._clover_generate_receipt(payment_data) + elif odoo_state == 'cancel': + self._set_canceled() + elif odoo_state == 'refund': + self._set_done() + self._post_process() + self._clover_generate_receipt(payment_data) + elif odoo_state == 'error': + error_msg = payment_data.get('error_message', _("Payment was declined by Clover.")) + self._set_error(error_msg) + else: + _logger.warning( + "Received unknown Clover status (%s) for transaction %s.", + status, self.reference, + ) + self._set_error( + _("Received data with unrecognized status: %s.", status) + ) + + def _create_payment(self, **extra_create_values): + """Override to route Clover payments directly to the bank account.""" + if self.provider_code != 'clover': + return super()._create_payment(**extra_create_values) + + self.ensure_one() + provider = self._get_provider_sudo() + reference = f'{self.reference} - {self.provider_reference or ""}' + payment_method_line = provider.journal_id.inbound_payment_method_line_ids\ + .filtered(lambda l: l.payment_provider_id == provider) + payment_values = { + 'amount': abs(self.amount), + 'payment_type': 'inbound' if self.amount > 0 else 'outbound', + 'currency_id': self.currency_id.id, + 'partner_id': self.partner_id.commercial_partner_id.id, + 'partner_type': 'customer', + 'journal_id': provider.journal_id.id, + 'company_id': provider.company_id.id, + 'payment_method_line_id': payment_method_line.id, + 'payment_token_id': self.token_id.id, + 'payment_transaction_id': self.id, + 'memo': reference, + 'write_off_line_vals': [], + 'invoice_ids': self.invoice_ids, + **extra_create_values, + } + + payment_term_lines = self.invoice_ids.line_ids.filtered( + lambda line: line.display_type == 'payment_term' + ) + if payment_term_lines: + payment_values['destination_account_id'] = payment_term_lines[0].account_id.id + + payment = self.env['account.payment'].create(payment_values) + + bank_account = provider.journal_id.default_account_id + if bank_account and bank_account.account_type == 'asset_cash': + payment.outstanding_account_id = bank_account + + payment.action_post() + self.payment_id = payment + + if self.operation == self.source_transaction_id.operation: + invoices = self.source_transaction_id.invoice_ids + else: + invoices = self.invoice_ids + invoices = invoices.filtered(lambda inv: inv.state != 'cancel') + if invoices: + invoices.filtered(lambda inv: inv.state == 'draft').action_post() + (payment.move_id.line_ids + invoices.line_ids).filtered( + lambda line: line.account_id == payment.destination_account_id + and not line.reconciled + ).reconcile() + + return payment + + def _extract_token_values(self, payment_data): + """Override of `payment` to return token data based on Clover data.""" + if self.provider_code != 'clover': + return super()._extract_token_values(payment_data) + + source = payment_data.get('source', {}) + card_details = clover_utils.extract_card_details(source) + + if not card_details: + _logger.warning( + "Tokenization requested but no card data in payment response." + ) + return {} + + return { + 'payment_details': card_details.get('last4', ''), + 'clover_source_token': source.get('id', ''), + } + + # === RECEIPT GENERATION === # + + def _clover_generate_receipt(self, payment_data=None): + """Store receipt data and generate a PDF receipt.""" + self.ensure_one() + if self.provider_code != 'clover' or not self.clover_charge_id: + return + + try: + self._clover_store_receipt_data(payment_data) + self._clover_attach_receipt_pdf() + except Exception: + _logger.exception( + "Receipt generation failed for transaction %s", self.reference, + ) + + def _clover_store_receipt_data(self, payment_data=None): + """Persist receipt-relevant fields as a JSON blob.""" + source = payment_data.get('source', {}) if payment_data else {} + + receipt = { + 'charge_id': self.clover_charge_id or '', + 'reference': self.reference, + 'status': payment_data.get('clover_status', '') if payment_data else '', + 'card_brand': source.get('brand', ''), + 'card_last4': str(source.get('last4', '')), + 'card_first6': str(source.get('first6', '')), + 'exp_month': source.get('exp_month', ''), + 'exp_year': source.get('exp_year', ''), + 'transaction_amount': float(self.amount), + 'currency': self.currency_id.name, + } + + self.clover_receipt_data = json.dumps(receipt) + + def _clover_attach_receipt_pdf(self): + """Render the QWeb receipt report and attach the PDF to the invoice.""" + invoice = self.invoice_ids[:1] + if not invoice: + return + + try: + report = self.env.ref('fusion_clover.action_report_clover_receipt') + pdf_content, _report_type = report._render_qweb_pdf(report.report_name, [self.id]) + except Exception: + _logger.debug("Could not render Clover receipt PDF for %s", self.reference) + return + + filename = f"Payment_Receipt_{self.reference}.pdf" + attachment = self.env['ir.attachment'].sudo().create({ + 'name': filename, + 'type': 'binary', + 'datas': base64.b64encode(pdf_content), + 'res_model': 'account.move', + 'res_id': invoice.id, + 'mimetype': 'application/pdf', + }) + + invoice.sudo().message_post( + body=_( + "Payment receipt generated for transaction %(ref)s.", + ref=self.reference, + ), + attachment_ids=[attachment.id], + ) + + def _get_clover_receipt_values(self): + """Parse the stored receipt JSON for use in QWeb templates.""" + self.ensure_one() + data = self.clover_receipt_data + if not data and self.source_transaction_id: + data = self.source_transaction_id.clover_receipt_data + if not data: + return {} + try: + return json.loads(data) + except (json.JSONDecodeError, TypeError): + return {} + + def _get_source_receipt_values(self): + """Return receipt values from the original sale transaction.""" + self.ensure_one() + if self.source_transaction_id and self.source_transaction_id.clover_receipt_data: + try: + return json.loads(self.source_transaction_id.clover_receipt_data) + except (json.JSONDecodeError, TypeError): + pass + return {} diff --git a/fusion_clover/models/res_config_settings.py b/fusion_clover/models/res_config_settings.py new file mode 100644 index 00000000..6b12b88a --- /dev/null +++ b/fusion_clover/models/res_config_settings.py @@ -0,0 +1,83 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + clover_surcharge_enabled = fields.Boolean( + string="Enable Credit Card Surcharge", + config_parameter='fusion_clover.surcharge_enabled', + ) + clover_surcharge_visa_rate = fields.Float( + string="Visa Rate (%)", + config_parameter='fusion_clover.surcharge_visa_rate', + default=2.5, + ) + clover_surcharge_mastercard_rate = fields.Float( + string="Mastercard Rate (%)", + config_parameter='fusion_clover.surcharge_mastercard_rate', + default=2.5, + ) + clover_surcharge_amex_rate = fields.Float( + string="Amex Rate (%)", + config_parameter='fusion_clover.surcharge_amex_rate', + default=3.5, + ) + clover_surcharge_debit_rate = fields.Float( + string="Debit Rate (%)", + config_parameter='fusion_clover.surcharge_debit_rate', + default=0.0, + ) + clover_surcharge_other_rate = fields.Float( + string="Other Cards Rate (%)", + config_parameter='fusion_clover.surcharge_other_rate', + default=2.5, + ) + clover_surcharge_product_id = fields.Many2one( + 'product.product', + string="Surcharge Product", + config_parameter='fusion_clover.surcharge_product_id', + help="The service product used for the credit card processing fee line.", + ) + + @api.model + def get_values(self): + res = super().get_values() + ICP = self.env['ir.config_parameter'].sudo() + product_id = int(ICP.get_param('fusion_clover.surcharge_product_id', '0') or 0) + if product_id and self.env['product.product'].sudo().browse(product_id).exists(): + res['clover_surcharge_product_id'] = product_id + else: + default = self.env.ref('fusion_clover.product_cc_processing_fee', raise_if_not_found=False) + res['clover_surcharge_product_id'] = default.id if default else False + return res + + def set_values(self): + super().set_values() + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param( + 'fusion_clover.surcharge_product_id', + str(self.clover_surcharge_product_id.id) if self.clover_surcharge_product_id else '0', + ) + + def action_open_clover_provider(self): + provider = self.env['payment.provider'].sudo().search( + [('code', '=', 'clover')], limit=1, + ) + if provider: + return { + 'type': 'ir.actions.act_window', + 'res_model': 'payment.provider', + 'res_id': provider.id, + 'view_mode': 'form', + 'target': 'current', + } + return { + 'type': 'ir.actions.act_window', + 'res_model': 'payment.provider', + 'view_mode': 'list,form', + 'target': 'current', + 'domain': [('code', '=', 'clover')], + } diff --git a/fusion_clover/models/sale_order.py b/fusion_clover/models/sale_order.py new file mode 100644 index 00000000..af1c9f31 --- /dev/null +++ b/fusion_clover/models/sale_order.py @@ -0,0 +1,70 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + clover_provider_enabled = fields.Boolean( + string="Clover Provider Enabled", + compute='_compute_clover_provider_enabled', + ) + + def _compute_clover_provider_enabled(self): + provider = self.env['payment.provider'].sudo().search([ + ('code', '=', 'clover'), + ('state', 'in', ('enabled', 'test')), + ], limit=1) + enabled = bool(provider) + for order in self: + order.clover_provider_enabled = enabled + + def action_clover_collect_payment(self): + """Create an invoice (if needed) and open the Clover payment wizard.""" + self.ensure_one() + + if self.state not in ('sale', 'done'): + raise UserError( + _("You can only collect payment on confirmed orders.") + ) + + invoice = self.invoice_ids.filtered( + lambda inv: inv.state == 'posted' + and inv.payment_state in ('not_paid', 'partial') + and inv.move_type == 'out_invoice' + )[:1] + + if not invoice: + draft_invoices = self.invoice_ids.filtered( + lambda inv: inv.state == 'draft' + and inv.move_type == 'out_invoice' + ) + if draft_invoices: + invoice = draft_invoices[0] + invoice.action_post() + else: + invoices = self._create_invoices() + if not invoices: + raise UserError( + _("Could not create an invoice for this order.") + ) + invoice = invoices[0] + invoice.action_post() + + return { + 'name': _("Collect Clover Payment"), + 'type': 'ir.actions.act_window', + 'res_model': 'clover.payment.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'active_model': 'account.move', + 'active_id': invoice.id, + }, + } diff --git a/fusion_clover/report/clover_receipt_report.xml b/fusion_clover/report/clover_receipt_report.xml new file mode 100644 index 00000000..2eb7c903 --- /dev/null +++ b/fusion_clover/report/clover_receipt_report.xml @@ -0,0 +1,14 @@ + + + + + Clover Payment Receipt + payment.transaction + qweb-pdf + fusion_clover.report_clover_receipt_document + fusion_clover.report_clover_receipt_document + 'Payment_Receipt_%s' % object.reference + report + + + diff --git a/fusion_clover/report/clover_receipt_templates.xml b/fusion_clover/report/clover_receipt_templates.xml new file mode 100644 index 00000000..1523f7eb --- /dev/null +++ b/fusion_clover/report/clover_receipt_templates.xml @@ -0,0 +1,251 @@ + + + + + + diff --git a/fusion_clover/security/ir.model.access.csv b/fusion_clover/security/ir.model.access.csv new file mode 100644 index 00000000..a8cbb899 --- /dev/null +++ b/fusion_clover/security/ir.model.access.csv @@ -0,0 +1,10 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_clover_payment_wizard_user,clover.payment.wizard.user,model_clover_payment_wizard,group_fusion_clover_user,1,1,1,0 +access_clover_payment_wizard_admin,clover.payment.wizard.admin,model_clover_payment_wizard,group_fusion_clover_admin,1,1,1,1 +access_clover_refund_wizard_user,clover.refund.wizard.user,model_clover_refund_wizard,group_fusion_clover_user,1,1,1,0 +access_clover_refund_wizard_admin,clover.refund.wizard.admin,model_clover_refund_wizard,group_fusion_clover_admin,1,1,1,1 +access_payment_provider_clover_user,payment.provider.clover.user,payment.model_payment_provider,group_fusion_clover_user,1,0,0,0 +access_payment_transaction_clover_user,payment.transaction.clover.user,payment.model_payment_transaction,group_fusion_clover_user,1,1,1,0 +access_payment_method_clover_user,payment.method.clover.user,payment.model_payment_method,group_fusion_clover_user,1,0,0,0 +access_clover_terminal_user,clover.terminal.user,model_clover_terminal,group_fusion_clover_user,1,0,0,0 +access_clover_terminal_admin,clover.terminal.admin,model_clover_terminal,group_fusion_clover_admin,1,1,1,1 diff --git a/fusion_clover/security/security.xml b/fusion_clover/security/security.xml new file mode 100644 index 00000000..71f17c53 --- /dev/null +++ b/fusion_clover/security/security.xml @@ -0,0 +1,29 @@ + + + + Fusion Clover + 48 + + + + Fusion Clover + 48 + + + + + User + 10 + + + + + + Administrator + 20 + + + + + + diff --git a/fusion_clover/static/description/icon.png b/fusion_clover/static/description/icon.png new file mode 100644 index 00000000..07b31a7e Binary files /dev/null and b/fusion_clover/static/description/icon.png differ diff --git a/fusion_clover/static/src/img/clover_logo.png b/fusion_clover/static/src/img/clover_logo.png new file mode 100644 index 00000000..73ade35d Binary files /dev/null and b/fusion_clover/static/src/img/clover_logo.png differ diff --git a/fusion_clover/static/src/interactions/payment_form.js b/fusion_clover/static/src/interactions/payment_form.js new file mode 100644 index 00000000..e608c71a --- /dev/null +++ b/fusion_clover/static/src/interactions/payment_form.js @@ -0,0 +1,468 @@ +/** @odoo-module **/ + +import { _t } from '@web/core/l10n/translation'; +import { patch } from '@web/core/utils/patch'; +import { rpc } from '@web/core/network/rpc'; + +import { PaymentForm } from '@payment/interactions/payment_form'; + +patch(PaymentForm.prototype, { + + setup() { + super.setup(); + this.cloverFormData = {}; + this._detectedCardType = 'other'; + this._selectedCardType = 'other'; + }, + + // #=== DOM MANIPULATION ===# + + async _prepareInlineForm(providerId, providerCode, paymentOptionId, paymentMethodCode, flow) { + if (providerCode !== 'clover') { + await super._prepareInlineForm(...arguments); + return; + } + + if (flow === 'token') { + return; + } + + this._setPaymentFlow('direct'); + + const radio = document.querySelector('input[name="o_payment_radio"]:checked'); + const inlineForm = this._getInlineForm(radio); + const cloverContainer = inlineForm.querySelector('[name="o_clover_payment_container"]'); + + if (!cloverContainer) { + return; + } + + const rawValues = cloverContainer.dataset['cloverInlineFormValues']; + if (rawValues) { + this.cloverFormData = JSON.parse(rawValues); + } + + this._setupCardFormatting(cloverContainer); + this._setupTerminalToggle(cloverContainer); + this._setupSurcharge(cloverContainer); + }, + + _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'; + } + return 'other'; + }, + + _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'); + + if (cardTypeSection) { + cardTypeSection.style.display = 'block'; + } + + const cardTypeRadios = container.querySelectorAll('input[name="clover_card_type"]'); + cardTypeRadios.forEach(radio => { + radio.addEventListener('change', () => { + this._selectedCardType = radio.value; + this._updateSurchargeDisplay(container); + }); + }); + + this._updateSurchargeDisplay(container); + }, + + _updateSurchargeDisplay(container) { + const surchargeConfig = this.cloverFormData.surcharge; + if (!surchargeConfig || !surchargeConfig.enabled) return; + + const cardType = this._detectedCardType !== 'other' + ? this._detectedCardType + : this._selectedCardType; + + const rate = surchargeConfig[cardType] || surchargeConfig['other'] || 0; + const amount = this.cloverFormData.minor_amount || 0; + + const baseAmount = amount / 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'); + + if (rateEl) rateEl.textContent = rate.toFixed(2); + if (amountEl) amountEl.textContent = `$${feeAmount.toFixed(2)}`; + if (noticeEl) { + noticeEl.style.display = rate > 0 ? 'block' : 'none'; + } + + const radioToCheck = container.querySelector( + `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' + ); + + if (!terminalCheckbox) { + return; + } + + 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'); + }); + 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'); + } + }); + } + }); + }, + + async _loadTerminals(container) { + const selectEl = container.querySelector('#clover_terminal_select'); + if (!selectEl || selectEl.options.length > 1) { + return; + } + + try { + const terminals = await rpc('/payment/clover/terminals', { + provider_id: this.cloverFormData.provider_id, + }); + + selectEl.innerHTML = ''; + if (terminals && terminals.length > 0) { + 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'); + selectEl.appendChild(option); + } + } catch { + const option = document.createElement('option'); + option.value = ''; + option.textContent = _t('Failed to load terminals'); + selectEl.appendChild(option); + } + }, + + // #=== PAYMENT FLOW ===# + + async _initiatePaymentFlow(providerCode, paymentOptionId, paymentMethodCode, flow) { + 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'); + + if (useTerminal && useTerminal.checked) { + const terminalId = inlineForm.querySelector('#clover_terminal_select').value; + if (!terminalId) { + this._displayErrorDialog( + _t("Terminal Required"), + _t("Please select a terminal device."), + ); + this._enableButton(); + return; + } + } else { + const validationError = this._validateCardInputs(inlineForm); + if (validationError) { + this._displayErrorDialog( + _t("Invalid Card Details"), + validationError, + ); + 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') { + 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'); + + if (useTerminal && useTerminal.checked) { + await this._processTerminalPayment(processingValues, inlineForm); + } else { + await this._processCardPayment(processingValues, inlineForm); + } + }, + + _getSelectedCardType(inlineForm) { + const checked = inlineForm.querySelector('input[name="clover_card_type"]:checked'); + 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' + ? this._detectedCardType + : this._getSelectedCardType(inlineForm); + + const [expMonth, expYear] = expiry.split('/').map(Number); + + try { + const result = 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_type: cardType, + }); + + if (result.error) { + this._displayErrorDialog( + _t("Payment Failed"), + result.error, + ); + this._enableButton(); + return; + } + + window.location.href = processingValues.return_url; + } catch (error) { + this._displayErrorDialog( + _t("Payment Processing Error"), + error.message || _t("An unexpected error occurred."), + ); + this._enableButton(); + } + }, + + async _processTerminalPayment(processingValues, inlineForm) { + const terminalId = inlineForm.querySelector('#clover_terminal_select').value; + const cardType = this._getSelectedCardType(inlineForm); + + try { + 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._enableButton(); + return; + } + + this._showTerminalWaitingScreen(processingValues, terminalId); + } catch (error) { + this._displayErrorDialog( + _t("Terminal Error"), + error.message || _t("Failed to send payment to terminal."), + ); + this._enableButton(); + } + }, + + _showTerminalWaitingScreen(processingValues, terminalId) { + const container = document.querySelector('.o_clover_payment_form'); + if (container) { + container.innerHTML = ` +
+
+ Loading... +
+
${_t("Waiting for terminal payment...")}
+

+ ${_t("Please complete the payment on the terminal device.")} +

+

+ ${_t("Checking status...")} +

+
+ `; + } + + this._pollTerminalStatus(processingValues, terminalId); + }, + + async _pollTerminalStatus(processingValues, terminalId, attempt = 0) { + const maxAttempts = 60; + const pollInterval = 3000; + + if (attempt >= maxAttempts) { + this._displayErrorDialog( + _t("Timeout"), + _t("Terminal payment timed out. Please check the device."), + ); + this._enableButton(); + return; + } + + try { + 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') { + 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') { + this._displayErrorDialog( + _t("Payment Declined"), + _t("The payment was declined at the terminal."), + ); + this._enableButton(); + return; + } + + if (statusEl) { + statusEl.textContent = _t("Status: ") + (result.status || _t("Pending")); + } + + setTimeout( + () => this._pollTerminalStatus(processingValues, terminalId, attempt + 1), + pollInterval, + ); + } catch { + setTimeout( + () => this._pollTerminalStatus(processingValues, terminalId, attempt + 1), + pollInterval, + ); + } + }, + +}); diff --git a/fusion_clover/utils.py b/fusion_clover/utils.py new file mode 100644 index 00000000..1854b56f --- /dev/null +++ b/fusion_clover/utils.py @@ -0,0 +1,177 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import uuid + +from odoo.addons.fusion_clover import const + + +def generate_idempotency_key(): + """Generate a unique idempotency key for Clover API requests.""" + return str(uuid.uuid4()) + + +def build_ecom_url(endpoint, is_test=False): + """Build a full Clover Ecommerce API URL. + + :param str endpoint: The API endpoint path (e.g., 'v1/charges'). + :param bool is_test: Whether to use the sandbox environment. + :return: The full API URL. + :rtype: str + """ + base = const.ECOM_BASE_URL_TEST if is_test else const.ECOM_BASE_URL + return f"{base}/{endpoint}" + + +def build_platform_url(endpoint, merchant_id=None, is_test=False): + """Build a full Clover Platform API URL. + + :param str endpoint: The API endpoint path. + :param str merchant_id: The merchant ID (optional). + :param bool is_test: Whether to use the sandbox environment. + :return: The full API URL. + :rtype: str + """ + base = const.API_BASE_URL_TEST if is_test else const.API_BASE_URL + if merchant_id: + return f"{base}/v3/merchants/{merchant_id}/{endpoint}" + return f"{base}/{endpoint}" + + +def build_ecom_headers(api_key, idempotency_key=None): + """Build the standard HTTP headers for a Clover Ecommerce API request. + + :param str api_key: The Clover API key (Bearer token). + :param str idempotency_key: Optional unique key for idempotency. + :return: The request headers dict. + :rtype: dict + """ + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': f'Bearer {api_key}', + } + if idempotency_key: + headers['idempotency-key'] = idempotency_key + return headers + + +def format_clover_amount(amount, currency): + """Convert a major currency amount to Clover's minor units (cents). + + :param float amount: The amount in major currency units. + :param recordset currency: The currency record. + :return: The amount in minor currency units (integer). + :rtype: int + """ + decimals = const.CURRENCY_DECIMALS.get(currency.name, 2) + return int(round(amount * (10 ** decimals))) + + +def parse_clover_amount(minor_amount, currency): + """Convert Clover's minor currency units back to major units. + + :param int minor_amount: The amount in minor currency units. + :param recordset currency: The currency record. + :return: The amount in major currency units. + :rtype: float + """ + decimals = const.CURRENCY_DECIMALS.get(currency.name, 2) + return minor_amount / (10 ** decimals) + + +def extract_card_details(source): + """Extract card details from a Clover charge source object. + + :param dict source: The Clover source object from a charge response. + :return: Dict with card brand, last4, expiration. + :rtype: dict + """ + if not source: + return {} + + brand_raw = source.get('brand', '') + brand_code = const.CARD_BRAND_MAPPING.get(brand_raw, 'card') + + return { + 'brand': brand_code, + 'last4': str(source.get('last4', '')), + 'exp_month': source.get('exp_month'), + 'exp_year': source.get('exp_year'), + 'first6': str(source.get('first6', '')), + } + + +def get_clover_status(status_str): + """Map a Clover charge status string to an Odoo transaction state. + + :param str status_str: The Clover charge status. + :return: The corresponding Odoo payment state. + :rtype: str + """ + for odoo_state, clover_statuses in const.STATUS_MAPPING.items(): + if status_str in clover_statuses: + return odoo_state + return 'error' + + +def build_charge_payload(amount, currency, source_token, capture=True, + description='', ecomind='ecom', + external_reference_id='', receipt_email='', + metadata=None): + """Build a Clover charge creation payload. + + :param float amount: The charge amount in major currency units. + :param recordset currency: The currency record. + :param str source_token: The Clover card token. + :param bool capture: Whether to capture immediately (True) or pre-auth (False). + :param str description: Optional charge description. + :param str ecomind: 'ecom' for customer-initiated, 'moto' for merchant-initiated. + :param str external_reference_id: External reference (max 12 chars). + :param str receipt_email: Email to send receipt to. + :param dict metadata: Optional key-value metadata. + :return: The Clover-formatted charge payload. + :rtype: dict + """ + minor_amount = format_clover_amount(amount, currency) + + payload = { + 'amount': minor_amount, + 'currency': currency.name.lower(), + 'source': source_token, + 'capture': capture, + 'ecomind': ecomind, + } + + if description: + payload['description'] = description + if external_reference_id: + payload['external_reference_id'] = external_reference_id[:12] + if receipt_email: + payload['receipt_email'] = receipt_email + if metadata: + payload['metadata'] = metadata + + return payload + + +def build_refund_payload(charge_id, amount=None, currency=None, reason=''): + """Build a Clover refund payload. + + :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. + :return: The Clover-formatted refund payload. + :rtype: dict + """ + payload = { + 'charge': charge_id, + } + + if amount is not None and currency: + payload['amount'] = format_clover_amount(amount, currency) + + if reason: + payload['reason'] = reason + + return payload diff --git a/fusion_clover/views/account_move_views.xml b/fusion_clover/views/account_move_views.xml new file mode 100644 index 00000000..0d674e56 --- /dev/null +++ b/fusion_clover/views/account_move_views.xml @@ -0,0 +1,84 @@ + + + + + account.move.form.clover.button + account.move + + 60 + + + + + + + + + +