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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ REFUND RECEIPT
+ PAYMENT RECEIPT
+
+
+
+
+
+
+
+
Date
+
+
+
+
+
+
+
+
+
+
+
Reference
+
+
+
+
Charge ID
+
+
+
+
Original Transaction
+
+
+
+
Type
+
+ REFUND
+ SALE
+
+
+
+
+
+
+
+
+
+
+
+
Card Type
+
+
+
+
Card Number
+
**** **** ****
+
+
+
Cardholder
+
+
+
+
+
+
+
+
+
+
+
+
+ REFUND TOTAL
+ TOTAL
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Status
+
+
+
+
+
+
+
+
+
+
+ Credit Note:
+ Invoice:
+
+
+
+ Customer:
+
+
+ Refund processed. The amount will be credited to your
+ card within 3-5 business days.
+
+
+ Thank you for your payment.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
ORIGINAL SALE RECEIPT
+
+ Reference for refund
+
+
+
+
+
+
+
+
Date
+
+
+
+
+
+
+
+
+
+
+
Reference
+
+
+
+
Charge ID
+
+
+
+
Type
+
SALE
+
+
+
+
+
+
+
+
+
+
+
Card Type
+
+
+
+
Card Number
+
**** **** ****
+
+
+
Cardholder
+
+
+
+
+
+
+
+
+
+
+
+
TOTAL
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Status
+
+
+
+
+
+
+
+
+
+
+ Invoice:
+
+
+ Customer:
+
+
+ This is the original sale transaction associated
+ with the refund on Page 1.
+
+ Use the "Sync Terminals" button on the Clover payment provider
+ to automatically fetch devices from your merchant account,
+ or add them manually.
+
+ Automatically add a surcharge line to invoices when collecting payment
+ via Poynt. The fee is calculated as a percentage of the invoice total.
+
+
+
+
+
+
+
+
+ Surcharge Rates by Card Type
+
+ Configure the processing fee percentage for each card brand.
+ The surcharge is added as a separate invoice line before payment.
+
+
+
+
+
+
+
+
%
+
+
+
+
+
+
+
%
+
+
+
+
+
+
+
%
+
+
+
+
+
+
+
%
+
+
+
+
+
+
+
%
+
+
+
+
+
+
+
+ Surcharge Product
+
+ The service product used for the processing fee invoice line.
+ Must be a service product with no taxes applied.
+
+
+
+
+
+
+
+
+
Quick Links
+
+
+
+ Payment Provider
+
+ Configure your Poynt API credentials, business ID,
+ and default terminal.
+
+
+
+
+
+
+
+
+ Terminals
+
+ View and manage your Poynt terminal devices.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Fusion Poynt Settings
+ res.config.settings
+ form
+ current
+
+
+
+
diff --git a/fusion_poynt/wizard/poynt_payment_wizard.py b/fusion_poynt/wizard/poynt_payment_wizard.py
index 39b9e4be..ce70de33 100644
--- a/fusion_poynt/wizard/poynt_payment_wizard.py
+++ b/fusion_poynt/wizard/poynt_payment_wizard.py
@@ -58,6 +58,37 @@ class PoyntPaymentWizard(models.TransientModel):
default='terminal',
)
+ # --- Card type & surcharge fields ---
+ card_type = fields.Selection(
+ selection=[
+ ('visa', "Visa"),
+ ('mastercard', "Mastercard"),
+ ('amex', "American Express"),
+ ('debit', "Debit"),
+ ('other', "Other"),
+ ],
+ string="Card Type",
+ )
+ surcharge_enabled = fields.Boolean(
+ compute='_compute_surcharge_enabled',
+ )
+ surcharge_rate = fields.Float(
+ string="Surcharge Rate (%)",
+ digits=(5, 2),
+ readonly=True,
+ )
+ surcharge_amount = fields.Monetary(
+ string="Surcharge Amount",
+ currency_field='currency_id',
+ readonly=True,
+ )
+ surcharge_applied = fields.Boolean(default=False)
+ original_amount = fields.Monetary(
+ string="Invoice Amount",
+ currency_field='currency_id',
+ readonly=True,
+ )
+
# --- Terminal fields ---
terminal_id = fields.Many2one(
'poynt.terminal',
@@ -102,6 +133,58 @@ class PoyntPaymentWizard(models.TransientModel):
readonly=True,
)
+ @api.depends_context('uid')
+ def _compute_surcharge_enabled(self):
+ enabled = self.env['ir.config_parameter'].sudo().get_param(
+ 'fusion_poynt.surcharge_enabled', 'False',
+ ) == 'True'
+ for rec in self:
+ rec.surcharge_enabled = enabled
+
+ @staticmethod
+ def _detect_card_brand(card_number):
+ 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'
+
+ def _get_surcharge_rate(self, card_type):
+ ICP = self.env['ir.config_parameter'].sudo()
+ rate_key = {
+ 'visa': 'fusion_poynt.surcharge_visa_rate',
+ 'mastercard': 'fusion_poynt.surcharge_mastercard_rate',
+ 'amex': 'fusion_poynt.surcharge_amex_rate',
+ 'debit': 'fusion_poynt.surcharge_debit_rate',
+ }.get(card_type, 'fusion_poynt.surcharge_other_rate')
+ return float(ICP.get_param(rate_key, '0') or 0)
+
+ @api.onchange('card_number')
+ def _onchange_card_number(self):
+ if self.payment_mode == 'card' and self.card_number:
+ self.card_type = self._detect_card_brand(self.card_number)
+
+ @api.onchange('card_type')
+ def _onchange_card_type(self):
+ if not self.card_type or not self.surcharge_enabled:
+ self.surcharge_rate = 0.0
+ self.surcharge_amount = 0.0
+ return
+ rate = self._get_surcharge_rate(self.card_type)
+ base_amount = self.original_amount or self.amount
+ self.surcharge_rate = rate
+ self.surcharge_amount = round(base_amount * rate / 100.0, 2)
+
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
@@ -112,6 +195,7 @@ class PoyntPaymentWizard(models.TransientModel):
invoice = self.env['account.move'].browse(invoice_id)
res['invoice_id'] = invoice.id
res['amount'] = invoice.amount_residual
+ res['original_amount'] = invoice.amount_residual
res['currency_id'] = invoice.currency_id.id
provider = self.env['payment.provider'].sudo().search([
@@ -135,10 +219,103 @@ class PoyntPaymentWizard(models.TransientModel):
if provider.poynt_default_terminal_id:
self.terminal_id = provider.poynt_default_terminal_id
+ def _apply_surcharge_if_needed(self):
+ """Add the surcharge invoice line if surcharge is enabled and not yet applied."""
+ if self.surcharge_applied or not self.surcharge_enabled:
+ return
+ if not self.card_type:
+ raise UserError(_("Please select the card type to calculate the surcharge."))
+
+ rate = self._get_surcharge_rate(self.card_type)
+ if rate <= 0:
+ return
+
+ base_amount = self.original_amount or self.amount
+ fee_amount = round(base_amount * rate / 100.0, 2)
+ if fee_amount <= 0:
+ return
+
+ ICP = self.env['ir.config_parameter'].sudo()
+ product_id = int(ICP.get_param('fusion_poynt.surcharge_product_id', '0') or 0)
+ product = self.env['product.product'].sudo().browse(product_id).exists()
+ if not product:
+ product = self.env.ref('fusion_poynt.product_cc_processing_fee', raise_if_not_found=False)
+ if not product:
+ raise UserError(
+ _("Surcharge product not configured. "
+ "Go to Settings > Fusion Poynt to set it up.")
+ )
+
+ invoice = self.invoice_id.sudo()
+
+ was_posted = invoice.state == 'posted'
+ if was_posted:
+ invoice.button_draft()
+
+ description = _("Credit Card Processing Fee (%(rate).2f%% surcharge)", rate=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()
+
+ self.write({
+ 'surcharge_applied': True,
+ 'surcharge_rate': rate,
+ 'surcharge_amount': fee_amount,
+ 'amount': invoice.amount_residual,
+ })
+
+ def _remove_surcharge_line(self):
+ """Remove the surcharge line from the invoice if it was applied."""
+ if not self.surcharge_applied:
+ return
+
+ ICP = self.env['ir.config_parameter'].sudo()
+ product_id = int(ICP.get_param('fusion_poynt.surcharge_product_id', '0') or 0)
+ product = self.env['product.product'].sudo().browse(product_id).exists()
+ if not product:
+ product = self.env.ref('fusion_poynt.product_cc_processing_fee', raise_if_not_found=False)
+ if not product:
+ return
+
+ invoice = self.invoice_id.sudo()
+ surcharge_lines = invoice.invoice_line_ids.filtered(
+ lambda l: l.product_id.id == product.id
+ )
+ if not surcharge_lines:
+ self.surcharge_applied = False
+ return
+
+ was_posted = invoice.state == 'posted'
+ if was_posted:
+ invoice.button_draft()
+
+ surcharge_lines.unlink()
+
+ if was_posted:
+ invoice.action_post()
+
+ self.write({
+ 'surcharge_applied': False,
+ 'surcharge_amount': 0.0,
+ 'surcharge_rate': 0.0,
+ 'amount': invoice.amount_residual,
+ })
+
def action_collect_payment(self):
"""Dispatch to the appropriate payment method."""
self.ensure_one()
+ self._apply_surcharge_if_needed()
+
if self.amount <= 0:
raise UserError(_("Payment amount must be greater than zero."))
@@ -187,6 +364,7 @@ class PoyntPaymentWizard(models.TransientModel):
except (ValidationError, UserError) as e:
self._cleanup_draft_transaction()
+ self._remove_surcharge_line()
self.write({
'state': 'error',
'status_message': str(e),
@@ -273,6 +451,31 @@ class PoyntPaymentWizard(models.TransientModel):
'poynt_status': status,
'funding_source': result.get('fundingSource', {}),
}
+
+ if status in ('DECLINED', 'FAILED', 'REFUND_FAILED'):
+ tx._set_error(
+ _("Payment was %(status)s by the processor.",
+ status=status.lower())
+ )
+ self._cleanup_draft_transaction()
+ self._remove_surcharge_line()
+ processor = result.get('processorResponse', {})
+ decline_msg = (
+ processor.get('statusMessage')
+ or processor.get('message')
+ or status.lower()
+ )
+ self.write({
+ 'state': 'error',
+ 'status_message': _(
+ "Payment %(status)s: %(reason)s",
+ status=status.lower(),
+ reason=decline_msg,
+ ),
+ 'poynt_transaction_ref': transaction_id,
+ })
+ return self._reopen_wizard()
+
tx._process('poynt', payment_data)
self.write({
@@ -288,6 +491,7 @@ class PoyntPaymentWizard(models.TransientModel):
except (ValidationError, UserError) as e:
self._cleanup_draft_transaction()
+ self._remove_surcharge_line()
self.write({
'state': 'error',
'status_message': str(e),
@@ -352,6 +556,7 @@ class PoyntPaymentWizard(models.TransientModel):
if status in ('DECLINED', 'VOIDED', 'REFUNDED'):
self._cleanup_draft_transaction()
+ self._remove_surcharge_line()
self.write({
'state': 'error',
'status_message': _(
@@ -473,6 +678,7 @@ class PoyntPaymentWizard(models.TransientModel):
"""Cancel the payment and clean up the draft transaction."""
self.ensure_one()
self._cleanup_draft_transaction()
+ self._remove_surcharge_line()
return {'type': 'ir.actions.act_window_close'}
def _cleanup_draft_transaction(self):
diff --git a/fusion_poynt/wizard/poynt_payment_wizard_views.xml b/fusion_poynt/wizard/poynt_payment_wizard_views.xml
index dd9cdf1b..4f87d754 100644
--- a/fusion_poynt/wizard/poynt_payment_wizard_views.xml
+++ b/fusion_poynt/wizard/poynt_payment_wizard_views.xml
@@ -9,6 +9,9 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ A surcharge line will be added to the invoice before payment.
+
+
+
+
diff --git a/fusion_rental/models/sale_order.py b/fusion_rental/models/sale_order.py
index fd480ae4..f2608847 100644
--- a/fusion_rental/models/sale_order.py
+++ b/fusion_rental/models/sale_order.py
@@ -68,6 +68,12 @@ class SaleOrder(models.Model):
copy=False,
help="Stored card used for automatic renewal payment collection.",
)
+ rental_apply_cc_fee = fields.Boolean(
+ string="Apply CC Processing Fee",
+ default=True,
+ help="When enabled, a credit card processing fee is added to "
+ "invoices charged via stored card during auto-renewal.",
+ )
rental_original_duration = fields.Integer(
string="Original Duration (Days)",
compute='_compute_rental_original_duration',
@@ -574,13 +580,17 @@ class SaleOrder(models.Model):
def _get_rental_only_lines(self):
"""Return order lines that should be invoiced on renewal.
- Excludes security deposits, delivery/installation, and any other
- one-time charges. Only lines flagged as rental by Odoo core
- (is_rental=True) are included.
+ Excludes security deposits, delivery/installation, fully returned
+ items, and any other one-time charges. Only lines flagged as
+ rental by Odoo core (is_rental=True) are included.
"""
self.ensure_one()
return self.order_line.filtered(
- lambda l: l.is_rental and not l.is_security_deposit
+ lambda l: (
+ l.is_rental
+ and not l.is_security_deposit
+ and l.qty_returned < l.product_uom_qty
+ )
)
def _get_renewal_amount(self):
diff --git a/fusion_rental/views/sale_order_views.xml b/fusion_rental/views/sale_order_views.xml
index d398fa56..e8e6e5c1 100644
--- a/fusion_rental/views/sale_order_views.xml
+++ b/fusion_rental/views/sale_order_views.xml
@@ -122,6 +122,8 @@
+