From 92369be6e0a07520ced630b4a3d17059f301d0f0 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Fri, 20 Mar 2026 11:46:41 -0400 Subject: [PATCH] changes --- fusion_clover/__init__.py | 17 + fusion_clover/__manifest__.py | 42 ++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 829 bytes .../__pycache__/__manifest__.cpython-312.pyc | Bin 0 -> 1186 bytes .../__pycache__/const.cpython-312.pyc | Bin 0 -> 1486 bytes .../__pycache__/utils.cpython-312.pyc | Bin 0 -> 6770 bytes fusion_clover/const.py | 85 +++ fusion_clover/controllers/__init__.py | 4 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 184 bytes .../__pycache__/main.cpython-312.pyc | Bin 0 -> 19942 bytes .../__pycache__/portal.cpython-312.pyc | Bin 0 -> 1681 bytes fusion_clover/controllers/main.py | 497 +++++++++++++ fusion_clover/controllers/portal.py | 51 ++ .../data/clover_receipt_email_template.xml | 97 +++ .../data/clover_surcharge_product.xml | 18 + fusion_clover/data/payment_provider_data.xml | 13 + fusion_clover/models/__init__.py | 9 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 393 bytes .../__pycache__/account_move.cpython-312.pyc | Bin 0 -> 8074 bytes .../clover_terminal.cpython-312.pyc | Bin 0 -> 8472 bytes .../payment_provider.cpython-312.pyc | Bin 0 -> 22072 bytes .../__pycache__/payment_token.cpython-312.pyc | Bin 0 -> 762 bytes .../payment_transaction.cpython-312.pyc | Bin 0 -> 25276 bytes .../res_config_settings.cpython-312.pyc | Bin 0 -> 3907 bytes .../__pycache__/sale_order.cpython-312.pyc | Bin 0 -> 2509 bytes fusion_clover/models/account_move.py | 200 ++++++ fusion_clover/models/clover_terminal.py | 282 ++++++++ fusion_clover/models/payment_provider.py | 608 ++++++++++++++++ fusion_clover/models/payment_token.py | 18 + fusion_clover/models/payment_transaction.py | 663 ++++++++++++++++++ fusion_clover/models/res_config_settings.py | 83 +++ fusion_clover/models/sale_order.py | 70 ++ .../report/clover_receipt_report.xml | 14 + .../report/clover_receipt_templates.xml | 251 +++++++ fusion_clover/security/ir.model.access.csv | 10 + fusion_clover/security/security.xml | 29 + fusion_clover/static/description/icon.png | Bin 0 -> 8848 bytes fusion_clover/static/src/img/clover_logo.png | Bin 0 -> 7938 bytes .../static/src/interactions/payment_form.js | 468 +++++++++++++ fusion_clover/utils.py | 177 +++++ fusion_clover/views/account_move_views.xml | 84 +++ fusion_clover/views/clover_terminal_views.xml | 109 +++ .../views/payment_clover_templates.xml | 124 ++++ .../views/payment_provider_views.xml | 54 ++ .../views/payment_transaction_views.xml | 40 ++ .../views/res_config_settings_views.xml | 128 ++++ fusion_clover/views/sale_order_views.xml | 24 + fusion_clover/wizard/__init__.py | 4 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 210 bytes .../clover_payment_wizard.cpython-312.pyc | Bin 0 -> 25653 bytes .../clover_refund_wizard.cpython-312.pyc | Bin 0 -> 10300 bytes fusion_clover/wizard/clover_payment_wizard.py | 628 +++++++++++++++++ .../wizard/clover_payment_wizard_views.xml | 157 +++++ fusion_clover/wizard/clover_refund_wizard.py | 476 +++++++++++++ .../wizard/clover_refund_wizard_views.xml | 114 +++ fusion_poynt/__manifest__.py | 3 + fusion_poynt/controllers/__init__.py | 1 + fusion_poynt/controllers/main.py | 106 ++- fusion_poynt/controllers/portal.py | 60 ++ fusion_poynt/data/poynt_surcharge_product.xml | 18 + fusion_poynt/models/__init__.py | 1 + fusion_poynt/models/payment_provider.py | 15 + fusion_poynt/models/payment_transaction.py | 125 +++- fusion_poynt/models/res_config_settings.py | 91 +++ .../static/src/interactions/payment_form.js | 95 +++ .../views/payment_poynt_templates.xml | 39 ++ .../views/res_config_settings_views.xml | 146 ++++ fusion_poynt/wizard/poynt_payment_wizard.py | 206 ++++++ .../wizard/poynt_payment_wizard_views.xml | 22 + fusion_rental/models/sale_order.py | 18 +- fusion_rental/views/sale_order_views.xml | 2 + 71 files changed, 6588 insertions(+), 8 deletions(-) create mode 100644 fusion_clover/__init__.py create mode 100644 fusion_clover/__manifest__.py create mode 100644 fusion_clover/__pycache__/__init__.cpython-312.pyc create mode 100644 fusion_clover/__pycache__/__manifest__.cpython-312.pyc create mode 100644 fusion_clover/__pycache__/const.cpython-312.pyc create mode 100644 fusion_clover/__pycache__/utils.cpython-312.pyc create mode 100644 fusion_clover/const.py create mode 100644 fusion_clover/controllers/__init__.py create mode 100644 fusion_clover/controllers/__pycache__/__init__.cpython-312.pyc create mode 100644 fusion_clover/controllers/__pycache__/main.cpython-312.pyc create mode 100644 fusion_clover/controllers/__pycache__/portal.cpython-312.pyc create mode 100644 fusion_clover/controllers/main.py create mode 100644 fusion_clover/controllers/portal.py create mode 100644 fusion_clover/data/clover_receipt_email_template.xml create mode 100644 fusion_clover/data/clover_surcharge_product.xml create mode 100644 fusion_clover/data/payment_provider_data.xml create mode 100644 fusion_clover/models/__init__.py create mode 100644 fusion_clover/models/__pycache__/__init__.cpython-312.pyc create mode 100644 fusion_clover/models/__pycache__/account_move.cpython-312.pyc create mode 100644 fusion_clover/models/__pycache__/clover_terminal.cpython-312.pyc create mode 100644 fusion_clover/models/__pycache__/payment_provider.cpython-312.pyc create mode 100644 fusion_clover/models/__pycache__/payment_token.cpython-312.pyc create mode 100644 fusion_clover/models/__pycache__/payment_transaction.cpython-312.pyc create mode 100644 fusion_clover/models/__pycache__/res_config_settings.cpython-312.pyc create mode 100644 fusion_clover/models/__pycache__/sale_order.cpython-312.pyc create mode 100644 fusion_clover/models/account_move.py create mode 100644 fusion_clover/models/clover_terminal.py create mode 100644 fusion_clover/models/payment_provider.py create mode 100644 fusion_clover/models/payment_token.py create mode 100644 fusion_clover/models/payment_transaction.py create mode 100644 fusion_clover/models/res_config_settings.py create mode 100644 fusion_clover/models/sale_order.py create mode 100644 fusion_clover/report/clover_receipt_report.xml create mode 100644 fusion_clover/report/clover_receipt_templates.xml create mode 100644 fusion_clover/security/ir.model.access.csv create mode 100644 fusion_clover/security/security.xml create mode 100644 fusion_clover/static/description/icon.png create mode 100644 fusion_clover/static/src/img/clover_logo.png create mode 100644 fusion_clover/static/src/interactions/payment_form.js create mode 100644 fusion_clover/utils.py create mode 100644 fusion_clover/views/account_move_views.xml create mode 100644 fusion_clover/views/clover_terminal_views.xml create mode 100644 fusion_clover/views/payment_clover_templates.xml create mode 100644 fusion_clover/views/payment_provider_views.xml create mode 100644 fusion_clover/views/payment_transaction_views.xml create mode 100644 fusion_clover/views/res_config_settings_views.xml create mode 100644 fusion_clover/views/sale_order_views.xml create mode 100644 fusion_clover/wizard/__init__.py create mode 100644 fusion_clover/wizard/__pycache__/__init__.cpython-312.pyc create mode 100644 fusion_clover/wizard/__pycache__/clover_payment_wizard.cpython-312.pyc create mode 100644 fusion_clover/wizard/__pycache__/clover_refund_wizard.cpython-312.pyc create mode 100644 fusion_clover/wizard/clover_payment_wizard.py create mode 100644 fusion_clover/wizard/clover_payment_wizard_views.xml create mode 100644 fusion_clover/wizard/clover_refund_wizard.py create mode 100644 fusion_clover/wizard/clover_refund_wizard_views.xml create mode 100644 fusion_poynt/controllers/portal.py create mode 100644 fusion_poynt/data/poynt_surcharge_product.xml create mode 100644 fusion_poynt/models/res_config_settings.py create mode 100644 fusion_poynt/views/res_config_settings_views.xml 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 0000000000000000000000000000000000000000..fa28085eaf0d94f17f0cf02dd00dbc3664df2f8f GIT binary patch literal 829 zcmb7Czi-n(6uz@#$09$8K&{G>g&~02g%NcrR7@E_NF~e3&0W+hj?a2$CkjfX4h(ea z*rh|)sQdx&7cj82LKVdl6BAoS#L|g(j)R~QNId(VzxVF@&ZkfBL#1+!U>v@GC(9Ng zUrjL&th1Qj$Ik)bgwufZX@^pSEyPyG%CL>t?%0S6uSloB9n8+iZ56*!l(uME>Z&jX z6$An_mTq5h5oBm6U;4nihBuD_0vp%#38n)wCfmR7<}6OR^~OHV@3=$96dGf?ZFEek{_m)GSIi1F5%68%l=f z3_->MDu{Diplz zfUyD0K!GtOGa1a$6Oxf;|Mni>JSKryga~sFbxy`!l3hS}?k!2dZj)gd0wMt*FltpsN08e|y{CPUl~i zSa&~;b=p2WI6A!laI2rU1c@N;;5#aq@-D?|Gm`2o?`n9TfS~BqNfZ$@%`?zc;T0^9 z9R@Mxpz~Hyp=#s1RvjGjc9kX0i8+=k?}VI!(D3@Rr3MJNz~B1q1OYxMJ_M{f@M1jg z8Q%XSgy(7n{rFHVeSpKbY1{U=YxOrX=SH^VWlMw1d6NxRa_7;n#&W+s-mz9Uvz3h> h-J99M<8ia$_QuVY(;oLMXDxG9FJ9T3c0*l7u0JpFs_Fm$ literal 0 HcmV?d00001 diff --git a/fusion_clover/__pycache__/const.cpython-312.pyc b/fusion_clover/__pycache__/const.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a6cc22c4581ff20f8452ffae691178acef65e9c GIT binary patch literal 1486 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 CL=cn! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0808c9472801acc1ae87be7d234b53991e93551e GIT binary patch literal 184 zcmX@j%ge<81eqsxW|jcy#~=<2FhLogWq^$73@HpLj5!Rsj8Tk?AT|?_%@oDN$WY0w z$?}pBs6>89oC^hF{{z`FSNp`8heM zMaBB@@tJv(Z_ DdNL@6 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..07a222fc0e24570ccf86ae0210b543d216ae0e6d GIT binary patch literal 19942 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..20ec0e693312a53a45e7540ca87005cf0fc73d4f GIT binary patch literal 393 zcmX|+O-chX7>2*he@jP2H-c7hIUBuzcm&syO&Dfk3C<)XNeexRXYdHFJb>T<3U%Yk zU7@&f<(t9!5gwkryyWMtEXRa-eSc9?M)Vmr`-%Q!cMo}@mRi#0oEMuXNoZ zI-1w3UZOI$W~o|fk(K+*n@6b;ocss2)~;^6GDB>o6OGXiY9^fYUg?=bd+h2?3S)8D zT~(~f#(0fQ0@8piAP*=4M!+-eK>9iEO)EQhErim_3vo4Ht&1Cki%#C!160D#+&Qp~ Y8DrlWv9k?LH*~Qf$R*YXo!SWszkp_C9RL6T literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..10f741d57316d75aec3d351daf1a8818026489e4 GIT binary patch literal 8074 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 WO&xsy-GQay#o_;j&uqMLu>S*mM7`z! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c2fb636482dc520b048cf56863f10543f8984313 GIT binary patch literal 8472 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8ebf6bacf53159a3df7c64665e05d769c6fe1125 GIT binary patch literal 22072 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d36b2e56ac93939461169d5a86892ef920dcf2af GIT binary patch literal 762 zcmYjPzi-n(6u$G1i(?w15TObpfrVPYsz|p^fu%?ksZ>a0@^Uh{tK;Aw;cO$hQ-=5U+aw>vzH&d05AG*$A${a=q1!G|nID?_j0-AOEN3G|6M!Nw^i!sj1ET^$+aFpe; zj2lB5jk(3BPtqhRIjZWi1GmO4j1xbm7;_tAp-K_Scn#y1g&!?477mjkWns>ws@&KW zxAOL&+5)TDa5d=%Fqbm?U03pMFZNc+?zm)9Yu>u3n;mp!tg|ainp@|!C=G&Ysg$H?H4&5I^Ljw@eUYP#U6QzlWhG_Y zd?J6gy1OMiRqfic#_Fk+AFyiz%GZ@`3-?=x5Y7>R&7a`rx!rkv=eylGvpc7I-|Wrf U+6A&8db2a>z3u%0BCHzz14Ez0X8-^I literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6a32fd0737b1a36a0bb11c397c5321e703e79295 GIT binary patch literal 25276 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 Rg_;|U*Bk$qqd2SWe*;pY%BKJT literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6d6d8e403b026552e978054456ef0ebf7e7423fb GIT binary patch literal 3907 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 literal 0 HcmV?d00001 diff --git a/fusion_clover/models/account_move.py b/fusion_clover/models/account_move.py new file mode 100644 index 00000000..1ad0b15a --- /dev/null +++ b/fusion_clover/models/account_move.py @@ -0,0 +1,200 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class AccountMove(models.Model): + _inherit = 'account.move' + + clover_refunded = fields.Boolean( + string="Refunded via Clover", + readonly=True, + copy=False, + default=False, + ) + clover_refund_count = fields.Integer( + string="Clover Refund Count", + compute='_compute_clover_refund_count', + ) + has_clover_receipt = fields.Boolean( + string="Has Clover Receipt", + compute='_compute_has_clover_receipt', + ) + clover_provider_enabled = fields.Boolean( + string="Clover Provider Enabled", + compute='_compute_clover_provider_enabled', + ) + + @api.depends('reversal_move_ids') + def _compute_clover_refund_count(self): + for move in self: + if move.move_type == 'out_invoice': + move.clover_refund_count = len(move.reversal_move_ids.filtered( + lambda r: r.clover_refunded + )) + else: + move.clover_refund_count = 0 + + def _compute_has_clover_receipt(self): + for move in self: + move.has_clover_receipt = bool(move._get_clover_transaction_for_receipt()) + + def _compute_clover_provider_enabled(self): + provider = self.env['payment.provider'].sudo().search([ + ('code', '=', 'clover'), + ('state', 'in', ('enabled', 'test')), + ], limit=1) + enabled = bool(provider) + for move in self: + move.clover_provider_enabled = enabled + + def action_view_clover_refunds(self): + """Open the credit notes linked to this invoice that were refunded via Clover.""" + self.ensure_one() + refund_moves = self.reversal_move_ids.filtered(lambda r: r.clover_refunded) + action = { + 'name': _("Clover Refunds"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.move', + 'view_mode': 'list,form', + 'domain': [('id', 'in', refund_moves.ids)], + 'context': {'default_move_type': 'out_refund'}, + } + if len(refund_moves) == 1: + action['view_mode'] = 'form' + action['res_id'] = refund_moves.id + return action + + def _get_clover_transaction_for_receipt(self): + """Find the Clover transaction linked to this invoice or credit note.""" + self.ensure_one() + domain = [ + ('provider_id.code', '=', 'clover'), + ('clover_charge_id', '!=', False), + ('state', '=', 'done'), + ] + if self.move_type == 'out_invoice': + domain.append(('invoice_ids', 'in', self.ids)) + elif self.move_type == 'out_refund': + domain += [ + ('operation', '=', 'refund'), + ('invoice_ids', 'in', self.ids), + ] + else: + return self.env['payment.transaction'] + + return self.env['payment.transaction'].sudo().search( + domain, order='id desc', limit=1, + ) + + def action_resend_clover_receipt(self): + """Resend the Clover payment/refund receipt email to the customer.""" + self.ensure_one() + tx = self._get_clover_transaction_for_receipt() + if not tx: + raise UserError(_( + "No completed Clover transaction found for this document." + )) + + template = self.env.ref( + 'fusion_clover.mail_template_clover_receipt', + raise_if_not_found=False, + ) + if not template: + raise UserError(_("Receipt email template not found.")) + + report = self.env.ref( + 'fusion_clover.action_report_clover_receipt', + raise_if_not_found=False, + ) + attachment_ids = [] + if report: + pdf_content, _content_type = report.sudo()._render_qweb_pdf( + report_ref='fusion_clover.action_report_clover_receipt', + res_ids=tx.ids, + ) + prefix = "Refund_Receipt" if self.move_type == 'out_refund' else "Payment_Receipt" + filename = f"{prefix}_{tx.reference}.pdf" + att = self.env['ir.attachment'].create({ + 'name': filename, + 'type': 'binary', + 'datas': base64.b64encode(pdf_content), + 'res_model': self._name, + 'res_id': self.id, + 'mimetype': 'application/pdf', + }) + attachment_ids = [att.id] + + template.send_mail(tx.id, force_send=True) + + is_refund = self.move_type == 'out_refund' + label = _("Refund") if is_refund else _("Payment") + self.message_post( + body=_( + "%(label)s receipt resent to %(email)s.", + label=label, + email=tx.partner_id.email, + ), + message_type='notification', + subtype_xmlid='mail.mt_note', + attachment_ids=attachment_ids, + ) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _("Receipt Sent"), + 'message': _("The receipt has been sent to %s.", + tx.partner_id.email), + 'type': 'success', + 'sticky': False, + }, + } + + def action_open_clover_payment_wizard(self): + """Open the Clover payment collection wizard for this invoice.""" + self.ensure_one() + return { + 'name': _("Collect Clover Payment"), + 'type': 'ir.actions.act_window', + 'res_model': 'clover.payment.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'active_model': 'account.move', + 'active_id': self.id, + }, + } + + def action_open_clover_refund_wizard(self): + """Open the Clover refund wizard for this credit note.""" + self.ensure_one() + return { + 'name': _("Refund via Clover"), + 'type': 'ir.actions.act_window', + 'res_model': 'clover.refund.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'active_model': 'account.move', + 'active_id': self.id, + }, + } + + def _get_original_clover_transaction(self): + """Find the Clover payment transaction from the reversed invoice.""" + self.ensure_one() + origin_invoice = self.reversed_entry_id + if not origin_invoice: + return self.env['payment.transaction'] + + return self.env['payment.transaction'].sudo().search([ + ('invoice_ids', 'in', origin_invoice.ids), + ('state', '=', 'done'), + ('provider_id.code', '=', 'clover'), + ('clover_charge_id', '!=', False), + ], order='id desc', limit=1) diff --git a/fusion_clover/models/clover_terminal.py b/fusion_clover/models/clover_terminal.py new file mode 100644 index 00000000..a78b0948 --- /dev/null +++ b/fusion_clover/models/clover_terminal.py @@ -0,0 +1,282 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +from odoo.addons.fusion_clover import utils as clover_utils + +_logger = logging.getLogger(__name__) + + +class CloverTerminal(models.Model): + _name = 'clover.terminal' + _description = 'Clover Terminal Device' + _order = 'name' + + name = fields.Char( + string="Terminal Name", + required=True, + help="A friendly name for this terminal. You can rename it to " + "identify the location (e.g. 'Front Desk', 'Back Office').", + ) + clover_device_name = fields.Char( + string="Clover Device Name", + readonly=True, + help="The original device name from Clover (read-only).", + ) + serial_number = fields.Char( + string="Serial Number", + help="The Clover device serial number. Used as X-Clover-Device-Id header.", + required=True, + copy=False, + ) + device_id = fields.Char( + string="Device ID", + help="The Clover device UUID from the Platform API.", + copy=False, + ) + provider_id = fields.Many2one( + 'payment.provider', + string="Payment Provider", + required=True, + ondelete='cascade', + domain="[('code', '=', 'clover')]", + ) + model_name = fields.Char( + string="Device Model", + readonly=True, + ) + status = fields.Selection( + selection=[ + ('online', "Online"), + ('offline', "Offline"), + ('unknown', "Unknown"), + ], + string="Status", + default='unknown', + readonly=True, + ) + last_seen = fields.Datetime( + string="Last Seen", + readonly=True, + ) + active = fields.Boolean( + default=True, + ) + + _unique_serial_provider = models.Constraint( + 'UNIQUE(serial_number, provider_id)', + 'A terminal with this serial number already exists for this provider.', + ) + + # === BUSINESS METHODS === # + + def _get_provider_sudo(self): + return self.provider_id.sudo() + + def action_refresh_status(self): + """Check terminal status via the Clover Platform API. + + First tries the Cloud Pay Display ping (POST /connect/v1/device/ping). + If that fails (e.g. REST Pay Display not configured), falls back to + the Platform API device endpoint (GET /v3/merchants/{mId}/devices/{deviceId}). + """ + self.ensure_one() + provider = self._get_provider_sudo() + + # --- Attempt 1: Cloud Pay Display ping --- + try: + provider._clover_terminal_request( + 'POST', 'device/ping', + serial_number=self.serial_number, + ) + self.write({ + 'status': 'online', + 'last_seen': fields.Datetime.now(), + }) + return provider._clover_notification( + _("Terminal '%(name)s' is online.", name=self.name), + 'success', + ) + except (ValidationError, UserError): + _logger.debug( + "Cloud ping failed for %s, trying Platform API.", + self.serial_number, + ) + + # --- Attempt 2: Platform API device lookup --- + if not self.device_id: + self.status = 'unknown' + return provider._clover_notification( + _("Could not reach terminal '%(name)s'. " + "Cloud Pay Display may not be configured for this merchant.", + name=self.name), + 'warning', + ) + + try: + result = provider._clover_make_platform_request( + 'GET', f'devices/{self.device_id}', + ) + # Clover Platform API doesn't return real-time online/offline, + # but a successful response means the device is registered. + self.write({ + 'status': 'online', + 'last_seen': fields.Datetime.now(), + }) + return provider._clover_notification( + _("Terminal '%(name)s' is registered and active on Clover.", + name=self.name), + 'success', + ) + except (ValidationError, UserError) as e: + self.status = 'offline' + return provider._clover_notification( + _("Could not reach terminal '%(name)s': %(error)s", + name=self.name, error=str(e)), + 'danger', + ) + + def action_send_payment(self, amount, currency, reference, capture=True): + """Send a payment request to the Clover terminal via Cloud REST Pay API. + + :param float amount: The payment amount in major currency units. + :param recordset currency: The currency record. + :param str reference: The Odoo payment reference / externalPaymentId. + :param bool capture: Whether to capture immediately (sale) or pre-auth. + :return: The terminal payment response. + :rtype: dict + :raises UserError: If the terminal is offline. + """ + self.ensure_one() + + if self.status == 'offline': + raise UserError( + _("Terminal '%(name)s' appears to be offline. " + "Please check the device and try again.", + name=self.name) + ) + + minor_amount = clover_utils.format_clover_amount(amount, currency) + + payload = { + 'amount': minor_amount, + 'externalPaymentId': reference, + 'capture': capture, + } + + provider = self._get_provider_sudo() + result = provider._clover_terminal_request( + 'POST', 'payments', + serial_number=self.serial_number, + payload=payload, + ) + + _logger.info( + "Payment request sent to terminal %s for %s %s (ref: %s)", + self.serial_number, amount, currency.name, reference, + ) + + return result + + def action_send_refund(self, payment_id, amount=None): + """Send a refund request to the terminal. + + :param str payment_id: The Clover payment UUID to refund. + :param int amount: Optional partial refund amount in cents. + :return: The terminal refund response. + :rtype: dict + """ + self.ensure_one() + + payload = {} + if amount: + payload['amount'] = amount + else: + payload['fullRefund'] = True + + provider = self._get_provider_sudo() + return provider._clover_terminal_request( + 'POST', f'payments/{payment_id}/refunds', + serial_number=self.serial_number, + payload=payload, + ) + + def action_check_payment_status(self, external_payment_id): + """Check the status of a terminal payment by externalPaymentId. + + :param str external_payment_id: The externalPaymentId sent with the payment. + :return: Dict with status and payment data. + :rtype: dict + """ + self.ensure_one() + + provider = self._get_provider_sudo() + try: + result = provider._clover_terminal_request( + 'GET', f'payments?externalPaymentId={external_payment_id}', + serial_number=self.serial_number, + ) + + payment = result.get('payment', {}) + if not payment: + return {'status': 'pending', 'message': 'Waiting for terminal response...'} + + clover_result = payment.get('result', '') + card_txn = payment.get('cardTransaction', {}) + state = card_txn.get('state', '') + + if clover_result == 'SUCCESS': + return { + 'status': state or 'CLOSED', + 'payment_id': payment.get('id', ''), + 'card_transaction': card_txn, + 'amount': payment.get('amount', 0), + 'result': clover_result, + } + + if clover_result in ('FAIL', 'DECLINED'): + return { + 'status': 'DECLINED', + 'message': payment.get('failureMessage', 'Payment declined'), + 'result': clover_result, + } + + return { + 'status': 'pending', + 'message': f'Status: {clover_result or "processing"}', + 'result': clover_result, + } + + except (ValidationError, UserError): + return {'status': 'error', 'message': 'Failed to check payment status.'} + + def action_display_welcome(self): + """Reset the terminal to the welcome screen.""" + self.ensure_one() + provider = self._get_provider_sudo() + try: + provider._clover_terminal_request( + 'POST', 'device/welcome', + serial_number=self.serial_number, + ) + return provider._clover_notification( + _("Welcome screen sent to '%(name)s'.", name=self.name), + 'success', + ) + except (ValidationError, UserError) as e: + _logger.warning("Failed to display welcome on terminal %s: %s", + self.serial_number, e) + return provider._clover_notification( + _("Could not send welcome screen to '%(name)s': %(error)s", + name=self.name, error=str(e)), + 'danger', + ) + + def _get_terminal_callback_url(self): + """Build the callback URL for terminal payment completion.""" + base_url = self._get_provider_sudo().get_base_url() + return f"{base_url}/payment/clover/terminal/callback" diff --git a/fusion_clover/models/payment_provider.py b/fusion_clover/models/payment_provider.py new file mode 100644 index 00000000..2928dc8e --- /dev/null +++ b/fusion_clover/models/payment_provider.py @@ -0,0 +1,608 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +import logging + +import requests + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +from odoo.addons.fusion_clover import const +from odoo.addons.fusion_clover import utils as clover_utils + +_logger = logging.getLogger(__name__) + + +class PaymentProvider(models.Model): + _inherit = 'payment.provider' + + code = fields.Selection( + selection_add=[('clover', "Clover")], + ondelete={'clover': 'set default'}, + ) + clover_api_key = fields.Char( + string="Ecommerce Private Token", + help="The private token from Clover's Ecommerce API Tokens page. " + "Used for online charges and refunds (scl.clover.com).", + required_if_provider='clover', + copy=False, + groups='base.group_system', + ) + clover_merchant_id = fields.Char( + string="Merchant ID", + help="The Clover merchant ID for this business.", + required_if_provider='clover', + copy=False, + ) + clover_rest_api_token = fields.Char( + string="REST API Token", + help="The merchant's REST API token from the Clover dashboard " + "(Setup > API Tokens). Used for Platform API (devices, orders) " + "and terminal payments (Cloud Pay Display). This is different " + "from the Ecommerce API token.", + copy=False, + groups='base.group_system', + ) + clover_app_id = fields.Char( + string="App ID (Client ID)", + help="The Clover App ID (client_id) from the developer dashboard. " + "Used for OAuth2 merchant authorization flow.", + copy=False, + ) + clover_app_secret = fields.Char( + string="App Secret", + help="The Clover App Secret (client_secret) from the developer dashboard.", + copy=False, + groups='base.group_system', + ) + clover_public_key = fields.Char( + string="Public API Key (PAKMS)", + help="The public token from Clover's Ecommerce API Tokens page. " + "Used for client-side tokenization. Safe to expose in the browser.", + copy=False, + ) + clover_default_terminal_id = fields.Many2one( + 'clover.terminal', + string="Default Terminal", + help="The default Clover terminal used for in-store payment collection. " + "Staff can override this per transaction.", + domain="[('provider_id', '=', id), ('active', '=', True)]", + ) + + # === COMPUTE METHODS === # + + def _compute_feature_support_fields(self): + """Override of `payment` to enable additional features.""" + super()._compute_feature_support_fields() + self.filtered(lambda p: p.code == 'clover').update({ + 'support_manual_capture': 'full_only', + 'support_refund': 'partial', + 'support_tokenization': True, + }) + + # === CRUD METHODS === # + + def _get_default_payment_method_codes(self): + """Override of `payment` to return the default payment method codes.""" + self.ensure_one() + if self.code != 'clover': + return super()._get_default_payment_method_codes() + return const.DEFAULT_PAYMENT_METHOD_CODES + + # === BUSINESS METHODS - API REQUESTS === # + + def _clover_make_ecom_request(self, method, endpoint, payload=None, params=None): + """Make an authenticated API request to the Clover Ecommerce API. + + :param str method: HTTP method (GET, POST, PUT, DELETE). + :param str endpoint: The API endpoint path (e.g., 'v1/charges'). + :param dict payload: The JSON request body (optional). + :param dict params: The query parameters (optional). + :return: The parsed JSON response. + :rtype: dict + :raises ValidationError: If the API request fails. + """ + self.ensure_one() + + is_test = self.state == 'test' + url = clover_utils.build_ecom_url(endpoint, is_test=is_test) + + idempotency_key = clover_utils.generate_idempotency_key() + headers = clover_utils.build_ecom_headers( + self.clover_api_key, idempotency_key=idempotency_key, + ) + + _logger.info( + "Clover Ecom API %s request to %s (idempotency=%s)", + method, url, idempotency_key, + ) + + try: + response = requests.request( + method, + url, + json=payload, + params=params, + headers=headers, + timeout=60, + ) + except requests.exceptions.RequestException as e: + _logger.error("Clover Ecom API request failed: %s", e) + raise ValidationError(_("Communication with Clover failed: %s", e)) + + if response.status_code in (202, 204): + return {} + + try: + result = response.json() + except ValueError: + if response.status_code < 400: + return {} + _logger.error("Clover returned non-JSON response: %s", response.text[:500]) + raise ValidationError(_("Clover returned an invalid response.")) + + if response.status_code >= 400: + error = result.get('error', {}) + error_msg = error.get('message', '') if isinstance(error, dict) else str(error) + error_code = error.get('code', '') if isinstance(error, dict) else '' + _logger.error( + "Clover Ecom API error %s: %s (code=%s)\n" + " URL: %s %s\n Payload: %s\n Response: %s", + response.status_code, error_msg, error_code, + method, url, + json.dumps(payload)[:2000] if payload else 'None', + response.text[:2000], + ) + raise ValidationError( + _("Clover API error (%(code)s): %(msg)s", + code=response.status_code, msg=error_msg or 'Unknown error') + ) + + return result + + def _clover_make_platform_request(self, method, endpoint, payload=None, params=None): + """Make an authenticated request to the Clover Platform API. + + :param str method: HTTP method. + :param str endpoint: The API endpoint path. + :param dict payload: The JSON request body (optional). + :param dict params: The query parameters (optional). + :return: The parsed JSON response. + :rtype: dict + :raises ValidationError: If the API request fails. + """ + self.ensure_one() + + is_test = self.state == 'test' + url = clover_utils.build_platform_url( + endpoint, merchant_id=self.clover_merchant_id, is_test=is_test, + ) + + # Platform API uses the REST API token, falling back to ecom key + api_token = self.clover_rest_api_token or self.clover_api_key + headers = clover_utils.build_ecom_headers(api_token) + + _logger.info("Clover Platform API %s request to %s", method, url) + + try: + response = requests.request( + method, + url, + json=payload, + params=params, + headers=headers, + timeout=60, + ) + except requests.exceptions.RequestException as e: + _logger.error("Clover Platform API request failed: %s", e) + raise ValidationError(_("Communication with Clover failed: %s", e)) + + if response.status_code in (202, 204): + return {} + + try: + result = response.json() + except ValueError: + if response.status_code < 400: + return {} + raise ValidationError(_("Clover returned an invalid response.")) + + if response.status_code >= 400: + error_msg = result.get('message', result.get('error', 'Unknown error')) + raise ValidationError( + _("Clover API error (%(code)s): %(msg)s", + code=response.status_code, msg=error_msg) + ) + + return result + + # === BUSINESS METHODS - CHARGE / TOKENIZE === # + + def _clover_create_charge(self, source_token, amount, currency, + capture=True, description='', ecomind='ecom', + external_reference_id='', receipt_email='', + metadata=None): + """Create a charge via the Clover Ecommerce API. + + :param str source_token: The Clover card token. + :param float amount: The charge amount in major currency units. + :param recordset currency: The currency record. + :param bool capture: Whether to capture immediately. + :param str description: Optional charge description. + :param str ecomind: 'ecom' or 'moto'. + :param str external_reference_id: External reference. + :param str receipt_email: Email for receipt. + :param dict metadata: Optional metadata. + :return: The charge response dict. + :rtype: dict + """ + self.ensure_one() + payload = clover_utils.build_charge_payload( + amount=amount, + currency=currency, + source_token=source_token, + capture=capture, + description=description, + ecomind=ecomind, + external_reference_id=external_reference_id, + receipt_email=receipt_email, + metadata=metadata, + ) + return self._clover_make_ecom_request('POST', 'v1/charges', payload=payload) + + def _clover_capture_charge(self, charge_id, amount=None, currency=None): + """Capture a previously authorized charge. + + :param str charge_id: The Clover charge ID. + :param float amount: Optional capture amount (for partial captures). + :param recordset currency: Optional currency record. + :return: The capture response dict. + :rtype: dict + """ + self.ensure_one() + payload = {} + if amount is not None and currency: + payload['amount'] = clover_utils.format_clover_amount(amount, currency) + return self._clover_make_ecom_request( + 'POST', f'v1/charges/{charge_id}/capture', payload=payload, + ) + + def _clover_create_refund(self, charge_id, amount=None, currency=None, reason=''): + """Create a refund via the Clover Ecommerce API. + + :param str charge_id: The Clover charge ID to refund. + :param float amount: Optional partial refund amount. + :param recordset currency: Optional currency record. + :param str reason: Optional reason. + :return: The refund response dict. + :rtype: dict + """ + self.ensure_one() + payload = clover_utils.build_refund_payload( + charge_id=charge_id, + amount=amount, + currency=currency, + reason=reason, + ) + return self._clover_make_ecom_request('POST', 'v1/refunds', payload=payload) + + # === BUSINESS METHODS - NON-REFERENCED CREDIT === # + + def _clover_create_credit(self, amount, currency, description=''): + """Issue a non-referenced credit (manual refund) via Clover Ecommerce API. + + This creates a credit without referencing an original charge. Useful + when the original transaction is too old for a referenced refund. + + Note: merchants must have manual refunds enabled by Clover support. + + :param float amount: The credit amount in major currency units. + :param recordset currency: The currency record. + :param str description: Optional description. + :return: The credit response dict. + :rtype: dict + """ + self.ensure_one() + minor_amount = clover_utils.format_clover_amount(amount, currency) + payload = { + 'amount': minor_amount, + 'currency': currency.name.lower(), + } + if description: + payload['description'] = description + return self._clover_make_ecom_request('POST', 'v1/credits', payload=payload) + + # === BUSINESS METHODS - VERIFICATION === # + + def _clover_get_charge(self, charge_id): + """Fetch a charge from the Clover Ecommerce API. + + :param str charge_id: The Clover charge ID. + :return: The charge data dict. + :rtype: dict + """ + self.ensure_one() + return self._clover_make_ecom_request('GET', f'v1/charges/{charge_id}') + + def _clover_verify_charge_not_reversed(self, charge_id): + """Check that a charge has not already been fully refunded or voided. + + :param str charge_id: The Clover charge ID. + :return: The charge data dict. + :rtype: dict + :raises UserError: If the charge is already refunded. + """ + self.ensure_one() + charge_data = self._clover_get_charge(charge_id) + status = charge_data.get('status', '') + if status == 'refunded': + raise UserError(_( + "This charge (%(charge_id)s) has already been fully refunded " + "on Clover. A duplicate refund cannot be issued.", + charge_id=charge_id, + )) + return charge_data + + # === BUSINESS METHODS - INLINE FORM === # + + def _clover_get_inline_form_values(self, amount, currency, partner_id, is_validation, + payment_method_sudo=None, **kwargs): + """Return serialized JSON of values needed for the inline payment form. + + :param float amount: The payment amount. + :param recordset currency: The currency of the transaction. + :param int partner_id: The partner ID. + :param bool is_validation: Whether this is a validation operation. + :param recordset payment_method_sudo: The sudoed payment method record. + :return: The JSON-serialized inline form values. + :rtype: str + """ + self.ensure_one() + + partner = self.env['res.partner'].browse(partner_id).exists() + minor_amount = clover_utils.format_clover_amount(amount, currency) if amount else 0 + + inline_form_values = { + 'provider_id': self.id, + 'merchant_id': self.clover_merchant_id, + 'public_key': self.clover_public_key or '', + 'currency_name': currency.name if currency else 'USD', + 'minor_amount': minor_amount, + 'capture_method': 'manual' if self.capture_manually else 'automatic', + 'is_test': self.state == 'test', + 'billing_details': { + 'name': partner.name or '', + 'email': partner.email or '', + 'phone': partner.phone or '', + 'address': { + 'line1': partner.street or '', + 'line2': partner.street2 or '', + 'city': partner.city or '', + 'state': partner.state_id.code or '', + 'country': partner.country_id.code or '', + 'postal_code': partner.zip or '', + }, + }, + 'is_tokenization_required': ( + self.allow_tokenization + and self._is_tokenization_required(**kwargs) + and payment_method_sudo + and payment_method_sudo.support_tokenization + ), + } + + ICP = self.env['ir.config_parameter'].sudo() + surcharge_enabled = ICP.get_param( + 'fusion_clover.surcharge_enabled', 'False', + ) == 'True' + if surcharge_enabled: + inline_form_values['surcharge'] = { + 'enabled': True, + 'visa': float(ICP.get_param('fusion_clover.surcharge_visa_rate', '0') or 0), + 'mastercard': float(ICP.get_param('fusion_clover.surcharge_mastercard_rate', '0') or 0), + 'amex': float(ICP.get_param('fusion_clover.surcharge_amex_rate', '0') or 0), + 'debit': float(ICP.get_param('fusion_clover.surcharge_debit_rate', '0') or 0), + 'other': float(ICP.get_param('fusion_clover.surcharge_other_rate', '0') or 0), + } + + return json.dumps(inline_form_values) + + # === BUSINESS METHODS - TERMINAL (REST Pay Display Cloud API) === # + + def _clover_terminal_request(self, method, endpoint, serial_number=None, + payload=None, params=None): + """Make a request to the Clover REST Pay Display Cloud API. + + Sends commands to Clover terminals through Clover's cloud (Cloud Pay Display). + + :param str method: HTTP method (GET, POST). + :param str endpoint: The API endpoint path (e.g., 'payments', 'device/ping'). + :param str serial_number: The device serial number (X-Clover-Device-Id). + :param dict payload: The JSON request body (optional). + :param dict params: The query parameters (optional). + :return: The parsed JSON response. + :rtype: dict + :raises ValidationError: If the API request fails. + """ + self.ensure_one() + + is_test = self.state == 'test' + base_url = const.CONNECT_BASE_URL_TEST if is_test else const.CONNECT_BASE_URL + url = f"{base_url}/{endpoint}" + + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': f'Bearer {self.clover_rest_api_token or self.clover_api_key}', + 'X-POS-ID': 'FusionCloverOdoo', + } + if serial_number: + headers['X-Clover-Device-Id'] = serial_number + + idempotency_key = clover_utils.generate_idempotency_key() + headers['Idempotency-Key'] = idempotency_key + + _logger.info( + "Clover Terminal API %s request to %s (device=%s)", + method, url, serial_number or 'none', + ) + + try: + response = requests.request( + method, + url, + json=payload, + params=params, + headers=headers, + timeout=120, + ) + except requests.exceptions.RequestException as e: + _logger.error("Clover Terminal API request failed: %s", e) + raise ValidationError(_("Communication with Clover terminal failed: %s", e)) + + if response.status_code in (202, 204): + return {} + + try: + result = response.json() + except ValueError: + if response.status_code < 400: + return {} + _logger.error("Clover Terminal returned non-JSON: %s", response.text[:500]) + raise ValidationError(_("Clover terminal returned an invalid response.")) + + if response.status_code >= 400: + error_msg = result.get('message', result.get('error', 'Unknown error')) + _logger.error( + "Clover Terminal API error %s: %s\n URL: %s %s", + response.status_code, error_msg, method, url, + ) + raise ValidationError( + _("Clover terminal error (%(code)s): %(msg)s", + code=response.status_code, msg=error_msg) + ) + + return result + + def _clover_get_merchant_devices(self): + """Fetch all devices provisioned to the merchant from the Platform API. + + :return: List of device dicts with id, serial, name, model. + :rtype: list[dict] + """ + self.ensure_one() + result = self._clover_make_platform_request('GET', 'devices') + elements = result.get('elements', []) + return [ + { + 'id': d.get('id', ''), + 'serial': d.get('serial', ''), + 'name': d.get('name', d.get('productName', 'Clover Device')), + 'model': d.get('model', d.get('productName', '')), + } + for d in elements + if d.get('serial') + ] + + def action_sync_terminals(self): + """Sync terminals from the Clover Platform API.""" + self.ensure_one() + if self.code != 'clover': + return + + try: + devices = self._clover_get_merchant_devices() + except (ValidationError, UserError) as e: + return self._clover_notification( + _("Failed to fetch devices: %(error)s", error=str(e)), + 'danger', + ) + + if not devices: + return self._clover_notification( + _("No devices found for this merchant."), + 'warning', + ) + + Terminal = self.env['clover.terminal'].sudo() + created = 0 + updated = 0 + + for device in devices: + serial = device['serial'] + existing = Terminal.search([ + ('serial_number', '=', serial), + ('provider_id', '=', self.id), + ], limit=1) + + if existing: + # Only update metadata; don't overwrite user-set name + vals = { + 'device_id': device['id'], + 'model_name': device['model'], + 'clover_device_name': device['name'], + } + existing.write(vals) + updated += 1 + else: + Terminal.create({ + 'name': device['name'], + 'clover_device_name': device['name'], + 'serial_number': serial, + 'device_id': device['id'], + 'model_name': device['model'], + 'provider_id': self.id, + }) + created += 1 + + return self._clover_notification( + _("Sync complete: %(created)s created, %(updated)s updated.", + created=created, updated=updated), + 'success', + ) + + # === ACTION METHODS === # + + def action_clover_test_connection(self): + """Test the connection to Clover by fetching merchant info. + + :return: A notification action with the result. + :rtype: dict + """ + self.ensure_one() + + try: + result = self._clover_make_platform_request('GET', '') + merchant_name = result.get('name', 'Unknown') + message = _( + "Connection successful. Merchant: %(name)s (ID: %(mid)s)", + name=merchant_name, + mid=self.clover_merchant_id, + ) + notification_type = 'success' + except (ValidationError, UserError) as e: + message = _("Connection failed: %(error)s", error=str(e)) + notification_type = 'danger' + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': message, + 'sticky': False, + 'type': notification_type, + }, + } + + def _clover_notification(self, message, notification_type='info'): + """Return a display_notification action.""" + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': message, + 'sticky': False, + 'type': notification_type, + }, + } diff --git a/fusion_clover/models/payment_token.py b/fusion_clover/models/payment_token.py new file mode 100644 index 00000000..615cec63 --- /dev/null +++ b/fusion_clover/models/payment_token.py @@ -0,0 +1,18 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + + +class PaymentToken(models.Model): + _inherit = 'payment.token' + + clover_source_token = fields.Char( + string="Clover Source Token", + help="The Clover multi-pay token (source ID) for recurring charges.", + readonly=True, + groups='base.group_system', + ) diff --git a/fusion_clover/models/payment_transaction.py b/fusion_clover/models/payment_transaction.py new file mode 100644 index 00000000..90ac1e24 --- /dev/null +++ b/fusion_clover/models/payment_transaction.py @@ -0,0 +1,663 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 +import json +import logging + +from werkzeug.urls import url_encode + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.urls import urljoin as url_join + +from odoo.addons.fusion_clover import const +from odoo.addons.fusion_clover import utils as clover_utils +from odoo.addons.fusion_clover.controllers.main import CloverController + +_logger = logging.getLogger(__name__) + + +class PaymentTransaction(models.Model): + _inherit = 'payment.transaction' + + clover_charge_id = fields.Char( + string="Clover Charge ID", + readonly=True, + copy=False, + ) + clover_refund_id = fields.Char( + string="Clover Refund ID", + readonly=True, + copy=False, + ) + clover_receipt_data = fields.Text( + string="Clover Receipt Data", + readonly=True, + copy=False, + help="JSON blob with receipt-relevant fields captured at payment time.", + ) + clover_order_id = fields.Char( + string="Clover Order ID", + readonly=True, + copy=False, + ) + clover_voided = fields.Boolean( + string="Voided", + default=False, + copy=False, + ) + clover_void_date = fields.Datetime( + string="Void Date", + readonly=True, + copy=False, + ) + + def _get_provider_sudo(self): + return self.provider_id.sudo() + + # === BUSINESS METHODS - PAYMENT FLOW === # + + def _get_specific_processing_values(self, processing_values): + """Override of payment to return Clover-specific processing values.""" + if self.provider_code != 'clover': + return super()._get_specific_processing_values(processing_values) + + if self.operation == 'online_token': + return {} + + provider = self._get_provider_sudo() + base_url = provider.get_base_url() + return_url = url_join( + base_url, + f'{CloverController._return_url}?{url_encode({"reference": self.reference})}', + ) + + return { + 'return_url': return_url, + 'merchant_id': provider.clover_merchant_id, + 'is_test': provider.state == 'test', + } + + def _send_payment_request(self): + """Override of `payment` to send a payment request to Clover.""" + if self.provider_code != 'clover': + return super()._send_payment_request() + + if self.operation in ('online_token', 'offline'): + return self._clover_process_token_payment() + + @staticmethod + def _detect_card_brand_from_details(payment_details): + """Detect card brand from the payment_details string on a token.""" + details = (payment_details or '').upper() + if 'AMEX' in details or 'AMERICAN_EXPRESS' in details: + return 'amex' + if 'VISA' in details: + return 'visa' + if 'MASTER' in details: + return 'mastercard' + return 'other' + + def _apply_token_surcharge(self): + """Apply surcharge to the linked invoice for token-based payments.""" + ICP = self.env['ir.config_parameter'].sudo() + if ICP.get_param('fusion_clover.surcharge_enabled', 'False') != 'True': + return + + if not self.token_id or not self.invoice_ids: + return + + for inv in self.invoice_ids: + sale_orders = inv.mapped('line_ids.sale_line_ids.order_id') + for so in sale_orders: + if getattr(so, 'is_rental_order', False): + if not getattr(so, 'rental_apply_cc_fee', True): + return + + card_type = self._detect_card_brand_from_details( + self.token_id.payment_details, + ) + rate_key = { + 'visa': 'fusion_clover.surcharge_visa_rate', + 'mastercard': 'fusion_clover.surcharge_mastercard_rate', + 'amex': 'fusion_clover.surcharge_amex_rate', + 'debit': 'fusion_clover.surcharge_debit_rate', + }.get(card_type, 'fusion_clover.surcharge_other_rate') + rate = float(ICP.get_param(rate_key, '0') or 0) + if rate <= 0: + return + + product_id = int(ICP.get_param('fusion_clover.surcharge_product_id', '0') or 0) + product = self.env['product.product'].sudo().browse(product_id).exists() + if not product: + product = self.env.ref( + 'fusion_clover.product_cc_processing_fee', raise_if_not_found=False, + ) + if not product: + _logger.warning("Surcharge product not configured; skipping token surcharge") + return + + total_fee = 0.0 + for invoice in self.invoice_ids.sudo(): + already_has = invoice.invoice_line_ids.filtered( + lambda l: l.product_id.id == product.id + ) + if already_has: + continue + + fee_amount = round(invoice.amount_residual * rate / 100.0, 2) + if fee_amount <= 0: + continue + + was_posted = invoice.state == 'posted' + if was_posted: + invoice.button_draft() + + description = "Credit Card Processing Fee (%.2f%% surcharge)" % rate + invoice.write({ + 'invoice_line_ids': [(0, 0, { + 'product_id': product.id, + 'name': description, + 'quantity': 1, + 'price_unit': fee_amount, + 'tax_ids': [(5, 0, 0)], + })], + }) + + if was_posted: + invoice.action_post() + + total_fee += fee_amount + + if total_fee > 0: + self.amount += total_fee + + def _clover_process_token_payment(self): + """Process a payment using a stored token (card on file).""" + try: + self._apply_token_surcharge() + + provider = self._get_provider_sudo() + capture = not provider.capture_manually + clover_token = self.token_id.clover_source_token + + if not clover_token: + self._set_error(_("No Clover token found for this saved card.")) + return + + result = provider._clover_create_charge( + source_token=clover_token, + amount=self.amount, + currency=self.currency_id, + capture=capture, + description=self.reference, + ecomind='moto', + metadata={'odoo_reference': self.reference}, + ) + + charge_id = result.get('id', '') + status = result.get('status', '') + + self.clover_charge_id = charge_id + self.provider_reference = charge_id + + payment_data = { + 'reference': self.reference, + 'clover_charge_id': charge_id, + 'clover_status': status, + 'source': result.get('source', {}), + } + + if status == 'failed': + outcome = result.get('outcome', {}) + decline_msg = outcome.get('type', status) + self._set_error( + _("Payment %(status)s: %(reason)s", + status=status, reason=decline_msg) + ) + return + + self._process('clover', payment_data) + except ValidationError as e: + self._set_error(str(e)) + + def _send_refund_request(self): + """Override of `payment` to send a refund request to Clover.""" + if self.provider_code != 'clover': + return super()._send_refund_request() + + source_tx = self.source_transaction_id + charge_id = source_tx.clover_charge_id or source_tx.provider_reference + refund_amount = abs(self.amount) + + try: + result = self._get_provider_sudo()._clover_create_refund( + charge_id=charge_id, + amount=refund_amount, + currency=self.currency_id, + reason=f'Refund for {source_tx.reference}', + ) + + refund_id = result.get('id', '') + self.provider_reference = refund_id + self.clover_refund_id = refund_id + + payment_data = { + 'reference': self.reference, + 'clover_charge_id': charge_id, + 'clover_refund_id': refund_id, + 'clover_status': result.get('status', 'succeeded'), + } + self._process('clover', payment_data) + except ValidationError as e: + self._set_error(str(e)) + + def _send_capture_request(self): + """Override of `payment` to send a capture request to Clover.""" + if self.provider_code != 'clover': + return super()._send_capture_request() + + source_tx = self.source_transaction_id + charge_id = source_tx.clover_charge_id or source_tx.provider_reference + + try: + result = self._get_provider_sudo()._clover_capture_charge( + charge_id=charge_id, + amount=self.amount, + currency=self.currency_id, + ) + + payment_data = { + 'reference': self.reference, + 'clover_charge_id': result.get('id', charge_id), + 'clover_status': result.get('status', 'succeeded'), + } + self._process('clover', payment_data) + except ValidationError as e: + self._set_error(str(e)) + + def _send_void_request(self): + """Override of `payment` to send a void (refund full) request to Clover. + + Clover doesn't have a dedicated void endpoint -- a full refund before + settlement acts as a void. + """ + if self.provider_code != 'clover': + return super()._send_void_request() + + source_tx = self.source_transaction_id + charge_id = source_tx.clover_charge_id or source_tx.provider_reference + + try: + result = self._get_provider_sudo()._clover_create_refund( + charge_id=charge_id, + reason=f'Void for {source_tx.reference}', + ) + + payment_data = { + 'reference': self.reference, + 'clover_charge_id': charge_id, + 'clover_refund_id': result.get('id', ''), + 'clover_status': result.get('status', 'succeeded'), + } + self._process('clover', payment_data) + except ValidationError as e: + self._set_error(str(e)) + + # === ACTION METHODS - VOID === # + + def action_clover_void(self): + """Void a confirmed Clover transaction (same-day, before settlement). + + Clover's Ecommerce API treats a full refund on an unsettled charge as a + void. We issue ``POST /v1/refunds`` for the full amount; if the charge + has already settled, the processor will decline the void (the user + should create a credit note and use the refund wizard instead). + """ + self.ensure_one() + if self.provider_code != 'clover': + raise ValidationError(_("This action is only available for Clover transactions.")) + if self.state != 'done': + raise ValidationError(_("Only confirmed transactions can be voided.")) + + charge_id = self.clover_charge_id or self.provider_reference + if not charge_id: + raise ValidationError(_("No Clover charge ID found.")) + + # Guard against double reversal + existing_refund = self.env['payment.transaction'].sudo().search([ + ('source_transaction_id', '=', self.id), + ('operation', '=', 'refund'), + ('state', '=', 'done'), + ], limit=1) + if existing_refund: + raise ValidationError(_( + "This transaction has already been refunded " + "(%(ref)s). Voiding would result in a double reversal.", + ref=existing_refund.reference, + )) + + provider = self._get_provider_sudo() + + # Verify on Clover the charge hasn't already been refunded + try: + charge_data = provider._clover_make_ecom_request( + 'GET', f'v1/charges/{charge_id}', + ) + charge_status = charge_data.get('status', '') + if charge_status == 'refunded': + raise ValidationError(_( + "This charge has already been refunded on Clover. " + "It cannot be voided again." + )) + except ValidationError: + raise + except Exception: + _logger.debug("Could not verify charge %s before void", charge_id) + + # Issue full refund (acts as void before settlement) + try: + result = provider._clover_create_refund( + charge_id=charge_id, + reason=f'Void for {self.reference}', + ) + except ValidationError as e: + error_msg = str(e) + if '400' in error_msg or 'declined' in error_msg.lower(): + raise ValidationError(_( + "Void declined by the payment processor. This usually " + "means the batch has already settled. Settled transactions " + "cannot be voided.\n\n" + "To reverse this payment, create a Credit Note on the " + "invoice and process a refund through the Clover refund " + "wizard." + )) + raise + + _logger.info( + "Clover void response: id=%s, status=%s", + result.get('id', ''), result.get('status', ''), + ) + + # Cancel the Odoo payment + if self.payment_id: + self.payment_id.sudo().action_cancel() + + self.sudo().write({ + 'state': 'cancel', + 'clover_voided': True, + 'clover_void_date': fields.Datetime.now(), + }) + + invoice = self.invoice_ids[:1] + if invoice: + invoice.sudo().message_post( + body=_( + "Payment voided: transaction %(ref)s was voided on Clover " + "(Clover Refund ID: %(refund_id)s).", + ref=self.reference, + refund_id=result.get('id', ''), + ), + ) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'success', + 'message': _("Transaction voided successfully on Clover."), + 'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'}, + }, + } + + # === BUSINESS METHODS - NOTIFICATION PROCESSING === # + + @api.model + def _search_by_reference(self, provider_code, payment_data): + """Override of payment to find the transaction based on Clover data.""" + if provider_code != 'clover': + return super()._search_by_reference(provider_code, payment_data) + + reference = payment_data.get('reference') + if reference: + tx = self.search([ + ('reference', '=', reference), + ('provider_code', '=', 'clover'), + ]) + else: + charge_id = payment_data.get('clover_charge_id') + if charge_id: + tx = self.search([ + ('clover_charge_id', '=', charge_id), + ('provider_code', '=', 'clover'), + ]) + else: + _logger.warning("Received Clover data with no reference or charge ID") + tx = self + + if not tx: + _logger.warning( + "No transaction found matching Clover reference %s", reference, + ) + + return tx + + def _apply_updates(self, payment_data): + """Override of `payment` to update the transaction based on Clover data.""" + if self.provider_code != 'clover': + return super()._apply_updates(payment_data) + + charge_id = payment_data.get('clover_charge_id') + if charge_id: + self.provider_reference = charge_id + self.clover_charge_id = charge_id + + refund_id = payment_data.get('clover_refund_id') + if refund_id: + self.clover_refund_id = refund_id + + source = payment_data.get('source', {}) + if source: + card_details = clover_utils.extract_card_details(source) + if card_details.get('brand'): + payment_method = self.env['payment.method']._get_from_code( + card_details['brand'], + mapping=const.CARD_BRAND_MAPPING, + ) + if payment_method: + self.payment_method_id = payment_method + + status = payment_data.get('clover_status', '') + if not status: + self._set_error(_("Received data with missing transaction status.")) + return + + odoo_state = clover_utils.get_clover_status(status) + + if odoo_state == 'authorized': + self._set_authorized() + elif odoo_state == 'done': + self._set_done() + self._post_process() + self._clover_generate_receipt(payment_data) + elif odoo_state == 'cancel': + self._set_canceled() + elif odoo_state == 'refund': + self._set_done() + self._post_process() + self._clover_generate_receipt(payment_data) + elif odoo_state == 'error': + error_msg = payment_data.get('error_message', _("Payment was declined by Clover.")) + self._set_error(error_msg) + else: + _logger.warning( + "Received unknown Clover status (%s) for transaction %s.", + status, self.reference, + ) + self._set_error( + _("Received data with unrecognized status: %s.", status) + ) + + def _create_payment(self, **extra_create_values): + """Override to route Clover payments directly to the bank account.""" + if self.provider_code != 'clover': + return super()._create_payment(**extra_create_values) + + self.ensure_one() + provider = self._get_provider_sudo() + reference = f'{self.reference} - {self.provider_reference or ""}' + payment_method_line = provider.journal_id.inbound_payment_method_line_ids\ + .filtered(lambda l: l.payment_provider_id == provider) + payment_values = { + 'amount': abs(self.amount), + 'payment_type': 'inbound' if self.amount > 0 else 'outbound', + 'currency_id': self.currency_id.id, + 'partner_id': self.partner_id.commercial_partner_id.id, + 'partner_type': 'customer', + 'journal_id': provider.journal_id.id, + 'company_id': provider.company_id.id, + 'payment_method_line_id': payment_method_line.id, + 'payment_token_id': self.token_id.id, + 'payment_transaction_id': self.id, + 'memo': reference, + 'write_off_line_vals': [], + 'invoice_ids': self.invoice_ids, + **extra_create_values, + } + + payment_term_lines = self.invoice_ids.line_ids.filtered( + lambda line: line.display_type == 'payment_term' + ) + if payment_term_lines: + payment_values['destination_account_id'] = payment_term_lines[0].account_id.id + + payment = self.env['account.payment'].create(payment_values) + + bank_account = provider.journal_id.default_account_id + if bank_account and bank_account.account_type == 'asset_cash': + payment.outstanding_account_id = bank_account + + payment.action_post() + self.payment_id = payment + + if self.operation == self.source_transaction_id.operation: + invoices = self.source_transaction_id.invoice_ids + else: + invoices = self.invoice_ids + invoices = invoices.filtered(lambda inv: inv.state != 'cancel') + if invoices: + invoices.filtered(lambda inv: inv.state == 'draft').action_post() + (payment.move_id.line_ids + invoices.line_ids).filtered( + lambda line: line.account_id == payment.destination_account_id + and not line.reconciled + ).reconcile() + + return payment + + def _extract_token_values(self, payment_data): + """Override of `payment` to return token data based on Clover data.""" + if self.provider_code != 'clover': + return super()._extract_token_values(payment_data) + + source = payment_data.get('source', {}) + card_details = clover_utils.extract_card_details(source) + + if not card_details: + _logger.warning( + "Tokenization requested but no card data in payment response." + ) + return {} + + return { + 'payment_details': card_details.get('last4', ''), + 'clover_source_token': source.get('id', ''), + } + + # === RECEIPT GENERATION === # + + def _clover_generate_receipt(self, payment_data=None): + """Store receipt data and generate a PDF receipt.""" + self.ensure_one() + if self.provider_code != 'clover' or not self.clover_charge_id: + return + + try: + self._clover_store_receipt_data(payment_data) + self._clover_attach_receipt_pdf() + except Exception: + _logger.exception( + "Receipt generation failed for transaction %s", self.reference, + ) + + def _clover_store_receipt_data(self, payment_data=None): + """Persist receipt-relevant fields as a JSON blob.""" + source = payment_data.get('source', {}) if payment_data else {} + + receipt = { + 'charge_id': self.clover_charge_id or '', + 'reference': self.reference, + 'status': payment_data.get('clover_status', '') if payment_data else '', + 'card_brand': source.get('brand', ''), + 'card_last4': str(source.get('last4', '')), + 'card_first6': str(source.get('first6', '')), + 'exp_month': source.get('exp_month', ''), + 'exp_year': source.get('exp_year', ''), + 'transaction_amount': float(self.amount), + 'currency': self.currency_id.name, + } + + self.clover_receipt_data = json.dumps(receipt) + + def _clover_attach_receipt_pdf(self): + """Render the QWeb receipt report and attach the PDF to the invoice.""" + invoice = self.invoice_ids[:1] + if not invoice: + return + + try: + report = self.env.ref('fusion_clover.action_report_clover_receipt') + pdf_content, _report_type = report._render_qweb_pdf(report.report_name, [self.id]) + except Exception: + _logger.debug("Could not render Clover receipt PDF for %s", self.reference) + return + + filename = f"Payment_Receipt_{self.reference}.pdf" + attachment = self.env['ir.attachment'].sudo().create({ + 'name': filename, + 'type': 'binary', + 'datas': base64.b64encode(pdf_content), + 'res_model': 'account.move', + 'res_id': invoice.id, + 'mimetype': 'application/pdf', + }) + + invoice.sudo().message_post( + body=_( + "Payment receipt generated for transaction %(ref)s.", + ref=self.reference, + ), + attachment_ids=[attachment.id], + ) + + def _get_clover_receipt_values(self): + """Parse the stored receipt JSON for use in QWeb templates.""" + self.ensure_one() + data = self.clover_receipt_data + if not data and self.source_transaction_id: + data = self.source_transaction_id.clover_receipt_data + if not data: + return {} + try: + return json.loads(data) + except (json.JSONDecodeError, TypeError): + return {} + + def _get_source_receipt_values(self): + """Return receipt values from the original sale transaction.""" + self.ensure_one() + if self.source_transaction_id and self.source_transaction_id.clover_receipt_data: + try: + return json.loads(self.source_transaction_id.clover_receipt_data) + except (json.JSONDecodeError, TypeError): + pass + return {} diff --git a/fusion_clover/models/res_config_settings.py b/fusion_clover/models/res_config_settings.py new file mode 100644 index 00000000..6b12b88a --- /dev/null +++ b/fusion_clover/models/res_config_settings.py @@ -0,0 +1,83 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + clover_surcharge_enabled = fields.Boolean( + string="Enable Credit Card Surcharge", + config_parameter='fusion_clover.surcharge_enabled', + ) + clover_surcharge_visa_rate = fields.Float( + string="Visa Rate (%)", + config_parameter='fusion_clover.surcharge_visa_rate', + default=2.5, + ) + clover_surcharge_mastercard_rate = fields.Float( + string="Mastercard Rate (%)", + config_parameter='fusion_clover.surcharge_mastercard_rate', + default=2.5, + ) + clover_surcharge_amex_rate = fields.Float( + string="Amex Rate (%)", + config_parameter='fusion_clover.surcharge_amex_rate', + default=3.5, + ) + clover_surcharge_debit_rate = fields.Float( + string="Debit Rate (%)", + config_parameter='fusion_clover.surcharge_debit_rate', + default=0.0, + ) + clover_surcharge_other_rate = fields.Float( + string="Other Cards Rate (%)", + config_parameter='fusion_clover.surcharge_other_rate', + default=2.5, + ) + clover_surcharge_product_id = fields.Many2one( + 'product.product', + string="Surcharge Product", + config_parameter='fusion_clover.surcharge_product_id', + help="The service product used for the credit card processing fee line.", + ) + + @api.model + def get_values(self): + res = super().get_values() + ICP = self.env['ir.config_parameter'].sudo() + product_id = int(ICP.get_param('fusion_clover.surcharge_product_id', '0') or 0) + if product_id and self.env['product.product'].sudo().browse(product_id).exists(): + res['clover_surcharge_product_id'] = product_id + else: + default = self.env.ref('fusion_clover.product_cc_processing_fee', raise_if_not_found=False) + res['clover_surcharge_product_id'] = default.id if default else False + return res + + def set_values(self): + super().set_values() + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param( + 'fusion_clover.surcharge_product_id', + str(self.clover_surcharge_product_id.id) if self.clover_surcharge_product_id else '0', + ) + + def action_open_clover_provider(self): + provider = self.env['payment.provider'].sudo().search( + [('code', '=', 'clover')], limit=1, + ) + if provider: + return { + 'type': 'ir.actions.act_window', + 'res_model': 'payment.provider', + 'res_id': provider.id, + 'view_mode': 'form', + 'target': 'current', + } + return { + 'type': 'ir.actions.act_window', + 'res_model': 'payment.provider', + 'view_mode': 'list,form', + 'target': 'current', + 'domain': [('code', '=', 'clover')], + } diff --git a/fusion_clover/models/sale_order.py b/fusion_clover/models/sale_order.py new file mode 100644 index 00000000..af1c9f31 --- /dev/null +++ b/fusion_clover/models/sale_order.py @@ -0,0 +1,70 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + clover_provider_enabled = fields.Boolean( + string="Clover Provider Enabled", + compute='_compute_clover_provider_enabled', + ) + + def _compute_clover_provider_enabled(self): + provider = self.env['payment.provider'].sudo().search([ + ('code', '=', 'clover'), + ('state', 'in', ('enabled', 'test')), + ], limit=1) + enabled = bool(provider) + for order in self: + order.clover_provider_enabled = enabled + + def action_clover_collect_payment(self): + """Create an invoice (if needed) and open the Clover payment wizard.""" + self.ensure_one() + + if self.state not in ('sale', 'done'): + raise UserError( + _("You can only collect payment on confirmed orders.") + ) + + invoice = self.invoice_ids.filtered( + lambda inv: inv.state == 'posted' + and inv.payment_state in ('not_paid', 'partial') + and inv.move_type == 'out_invoice' + )[:1] + + if not invoice: + draft_invoices = self.invoice_ids.filtered( + lambda inv: inv.state == 'draft' + and inv.move_type == 'out_invoice' + ) + if draft_invoices: + invoice = draft_invoices[0] + invoice.action_post() + else: + invoices = self._create_invoices() + if not invoices: + raise UserError( + _("Could not create an invoice for this order.") + ) + invoice = invoices[0] + invoice.action_post() + + return { + 'name': _("Collect Clover Payment"), + 'type': 'ir.actions.act_window', + 'res_model': 'clover.payment.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'active_model': 'account.move', + 'active_id': invoice.id, + }, + } diff --git a/fusion_clover/report/clover_receipt_report.xml b/fusion_clover/report/clover_receipt_report.xml new file mode 100644 index 00000000..2eb7c903 --- /dev/null +++ b/fusion_clover/report/clover_receipt_report.xml @@ -0,0 +1,14 @@ + + + + + Clover Payment Receipt + payment.transaction + qweb-pdf + fusion_clover.report_clover_receipt_document + fusion_clover.report_clover_receipt_document + 'Payment_Receipt_%s' % object.reference + report + + + diff --git a/fusion_clover/report/clover_receipt_templates.xml b/fusion_clover/report/clover_receipt_templates.xml new file mode 100644 index 00000000..1523f7eb --- /dev/null +++ b/fusion_clover/report/clover_receipt_templates.xml @@ -0,0 +1,251 @@ + + + + + + diff --git a/fusion_clover/security/ir.model.access.csv b/fusion_clover/security/ir.model.access.csv new file mode 100644 index 00000000..a8cbb899 --- /dev/null +++ b/fusion_clover/security/ir.model.access.csv @@ -0,0 +1,10 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_clover_payment_wizard_user,clover.payment.wizard.user,model_clover_payment_wizard,group_fusion_clover_user,1,1,1,0 +access_clover_payment_wizard_admin,clover.payment.wizard.admin,model_clover_payment_wizard,group_fusion_clover_admin,1,1,1,1 +access_clover_refund_wizard_user,clover.refund.wizard.user,model_clover_refund_wizard,group_fusion_clover_user,1,1,1,0 +access_clover_refund_wizard_admin,clover.refund.wizard.admin,model_clover_refund_wizard,group_fusion_clover_admin,1,1,1,1 +access_payment_provider_clover_user,payment.provider.clover.user,payment.model_payment_provider,group_fusion_clover_user,1,0,0,0 +access_payment_transaction_clover_user,payment.transaction.clover.user,payment.model_payment_transaction,group_fusion_clover_user,1,1,1,0 +access_payment_method_clover_user,payment.method.clover.user,payment.model_payment_method,group_fusion_clover_user,1,0,0,0 +access_clover_terminal_user,clover.terminal.user,model_clover_terminal,group_fusion_clover_user,1,0,0,0 +access_clover_terminal_admin,clover.terminal.admin,model_clover_terminal,group_fusion_clover_admin,1,1,1,1 diff --git a/fusion_clover/security/security.xml b/fusion_clover/security/security.xml new file mode 100644 index 00000000..71f17c53 --- /dev/null +++ b/fusion_clover/security/security.xml @@ -0,0 +1,29 @@ + + + + Fusion Clover + 48 + + + + Fusion Clover + 48 + + + + + User + 10 + + + + + + Administrator + 20 + + + + + + diff --git a/fusion_clover/static/description/icon.png b/fusion_clover/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..07b31a7eb86310fc52e7a9acf2d3915f47adf287 GIT binary patch literal 8848 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 literal 0 HcmV?d00001 diff --git a/fusion_clover/static/src/interactions/payment_form.js b/fusion_clover/static/src/interactions/payment_form.js new file mode 100644 index 00000000..e608c71a --- /dev/null +++ b/fusion_clover/static/src/interactions/payment_form.js @@ -0,0 +1,468 @@ +/** @odoo-module **/ + +import { _t } from '@web/core/l10n/translation'; +import { patch } from '@web/core/utils/patch'; +import { rpc } from '@web/core/network/rpc'; + +import { PaymentForm } from '@payment/interactions/payment_form'; + +patch(PaymentForm.prototype, { + + setup() { + super.setup(); + this.cloverFormData = {}; + this._detectedCardType = 'other'; + this._selectedCardType = 'other'; + }, + + // #=== DOM MANIPULATION ===# + + async _prepareInlineForm(providerId, providerCode, paymentOptionId, paymentMethodCode, flow) { + if (providerCode !== 'clover') { + await super._prepareInlineForm(...arguments); + return; + } + + if (flow === 'token') { + return; + } + + this._setPaymentFlow('direct'); + + const radio = document.querySelector('input[name="o_payment_radio"]:checked'); + const inlineForm = this._getInlineForm(radio); + const cloverContainer = inlineForm.querySelector('[name="o_clover_payment_container"]'); + + if (!cloverContainer) { + return; + } + + const rawValues = cloverContainer.dataset['cloverInlineFormValues']; + if (rawValues) { + this.cloverFormData = JSON.parse(rawValues); + } + + this._setupCardFormatting(cloverContainer); + this._setupTerminalToggle(cloverContainer); + this._setupSurcharge(cloverContainer); + }, + + _detectCardBrand(number) { + const num = (number || '').replace(/\D/g, ''); + if (num.length < 2) return 'other'; + const prefix2 = num.substring(0, 2); + if (prefix2 === '34' || prefix2 === '37') return 'amex'; + if (num[0] === '4') return 'visa'; + const p2 = parseInt(prefix2, 10); + if (p2 >= 51 && p2 <= 55) return 'mastercard'; + if (num.length >= 4) { + const p4 = parseInt(num.substring(0, 4), 10); + if (p4 >= 2221 && p4 <= 2720) return 'mastercard'; + } + return 'other'; + }, + + _setupSurcharge(container) { + const surchargeConfig = this.cloverFormData.surcharge; + if (!surchargeConfig || !surchargeConfig.enabled) return; + + const cardTypeSection = container.querySelector('.o_clover_card_type_section'); + const surchargeNotice = container.querySelector('.o_clover_surcharge_notice'); + + if (cardTypeSection) { + cardTypeSection.style.display = 'block'; + } + + const cardTypeRadios = container.querySelectorAll('input[name="clover_card_type"]'); + cardTypeRadios.forEach(radio => { + radio.addEventListener('change', () => { + this._selectedCardType = radio.value; + this._updateSurchargeDisplay(container); + }); + }); + + this._updateSurchargeDisplay(container); + }, + + _updateSurchargeDisplay(container) { + const surchargeConfig = this.cloverFormData.surcharge; + if (!surchargeConfig || !surchargeConfig.enabled) return; + + const cardType = this._detectedCardType !== 'other' + ? this._detectedCardType + : this._selectedCardType; + + const rate = surchargeConfig[cardType] || surchargeConfig['other'] || 0; + const amount = this.cloverFormData.minor_amount || 0; + + const baseAmount = amount / 100; + const feeAmount = Math.round(baseAmount * rate) / 100; + + const rateEl = container.querySelector('#clover_surcharge_rate'); + const amountEl = container.querySelector('#clover_surcharge_amount'); + const noticeEl = container.querySelector('.o_clover_surcharge_notice'); + + if (rateEl) rateEl.textContent = rate.toFixed(2); + if (amountEl) amountEl.textContent = `$${feeAmount.toFixed(2)}`; + if (noticeEl) { + noticeEl.style.display = rate > 0 ? 'block' : 'none'; + } + + const radioToCheck = container.querySelector( + `input[name="clover_card_type"][value="${cardType}"]` + ); + if (radioToCheck && !radioToCheck.checked) { + radioToCheck.checked = true; + } + }, + + _setupCardFormatting(container) { + const cardInput = container.querySelector('#clover_card_number'); + if (cardInput) { + cardInput.addEventListener('input', (e) => { + let value = e.target.value.replace(/\D/g, ''); + let formatted = ''; + for (let i = 0; i < value.length && i < 16; i++) { + if (i > 0 && i % 4 === 0) { + formatted += ' '; + } + formatted += value[i]; + } + e.target.value = formatted; + + const detected = this._detectCardBrand(value); + if (detected !== this._detectedCardType) { + this._detectedCardType = detected; + if (detected !== 'other') { + this._selectedCardType = detected; + } + this._updateSurchargeDisplay( + e.target.closest('.o_clover_payment_form') + ); + } + }); + } + + const expiryInput = container.querySelector('#clover_expiry'); + if (expiryInput) { + expiryInput.addEventListener('input', (e) => { + let value = e.target.value.replace(/\D/g, ''); + if (value.length >= 2) { + value = value.substring(0, 2) + '/' + value.substring(2, 4); + } + e.target.value = value; + }); + } + }, + + _setupTerminalToggle(container) { + const terminalCheckbox = container.querySelector('#clover_use_terminal'); + const terminalSelect = container.querySelector('#clover_terminal_select_wrapper'); + const cardFields = container.querySelectorAll( + '#clover_card_number, #clover_expiry, #clover_cvv, #clover_cardholder' + ); + + if (!terminalCheckbox) { + return; + } + + terminalCheckbox.addEventListener('change', () => { + if (terminalCheckbox.checked) { + if (terminalSelect) { + terminalSelect.style.display = 'block'; + } + cardFields.forEach(f => { + f.closest('.mb-3').style.display = 'none'; + f.removeAttribute('required'); + }); + this._loadTerminals(container); + } else { + if (terminalSelect) { + terminalSelect.style.display = 'none'; + } + cardFields.forEach(f => { + f.closest('.mb-3').style.display = 'block'; + if (f.id !== 'clover_cardholder') { + f.setAttribute('required', 'required'); + } + }); + } + }); + }, + + async _loadTerminals(container) { + const selectEl = container.querySelector('#clover_terminal_select'); + if (!selectEl || selectEl.options.length > 1) { + return; + } + + try { + const terminals = await rpc('/payment/clover/terminals', { + provider_id: this.cloverFormData.provider_id, + }); + + selectEl.innerHTML = ''; + if (terminals && terminals.length > 0) { + terminals.forEach(t => { + const option = document.createElement('option'); + option.value = t.id; + option.textContent = `${t.name} (${t.status})`; + selectEl.appendChild(option); + }); + } else { + const option = document.createElement('option'); + option.value = ''; + option.textContent = _t('No terminals available'); + selectEl.appendChild(option); + } + } catch { + const option = document.createElement('option'); + option.value = ''; + option.textContent = _t('Failed to load terminals'); + selectEl.appendChild(option); + } + }, + + // #=== PAYMENT FLOW ===# + + async _initiatePaymentFlow(providerCode, paymentOptionId, paymentMethodCode, flow) { + if (providerCode !== 'clover' || flow === 'token') { + await super._initiatePaymentFlow(...arguments); + return; + } + + const radio = document.querySelector('input[name="o_payment_radio"]:checked'); + const inlineForm = this._getInlineForm(radio); + const useTerminal = inlineForm.querySelector('#clover_use_terminal'); + + if (useTerminal && useTerminal.checked) { + const terminalId = inlineForm.querySelector('#clover_terminal_select').value; + if (!terminalId) { + this._displayErrorDialog( + _t("Terminal Required"), + _t("Please select a terminal device."), + ); + this._enableButton(); + return; + } + } else { + const validationError = this._validateCardInputs(inlineForm); + if (validationError) { + this._displayErrorDialog( + _t("Invalid Card Details"), + validationError, + ); + this._enableButton(); + return; + } + } + + await super._initiatePaymentFlow(...arguments); + }, + + _validateCardInputs(inlineForm) { + const cardNumber = inlineForm.querySelector('#clover_card_number'); + const expiry = inlineForm.querySelector('#clover_expiry'); + const cvv = inlineForm.querySelector('#clover_cvv'); + + const cardDigits = cardNumber.value.replace(/\D/g, ''); + if (cardDigits.length < 13 || cardDigits.length > 19) { + return _t("Please enter a valid card number."); + } + + const expiryValue = expiry.value; + if (!/^\d{2}\/\d{2}$/.test(expiryValue)) { + return _t("Please enter a valid expiry date (MM/YY)."); + } + + const [month, year] = expiryValue.split('/').map(Number); + if (month < 1 || month > 12) { + return _t("Invalid expiry month."); + } + + const now = new Date(); + const expiryDate = new Date(2000 + year, month); + if (expiryDate <= now) { + return _t("Card has expired."); + } + + const cvvValue = cvv.value.replace(/\D/g, ''); + if (cvvValue.length < 3 || cvvValue.length > 4) { + return _t("Please enter a valid CVV."); + } + + return null; + }, + + async _processDirectFlow(providerCode, paymentOptionId, paymentMethodCode, processingValues) { + if (providerCode !== 'clover') { + await super._processDirectFlow(...arguments); + return; + } + + const radio = document.querySelector('input[name="o_payment_radio"]:checked'); + const inlineForm = this._getInlineForm(radio); + const useTerminal = inlineForm.querySelector('#clover_use_terminal'); + + if (useTerminal && useTerminal.checked) { + await this._processTerminalPayment(processingValues, inlineForm); + } else { + await this._processCardPayment(processingValues, inlineForm); + } + }, + + _getSelectedCardType(inlineForm) { + const checked = inlineForm.querySelector('input[name="clover_card_type"]:checked'); + return checked ? checked.value : 'other'; + }, + + async _processCardPayment(processingValues, inlineForm) { + const cardNumber = inlineForm.querySelector('#clover_card_number').value.replace(/\D/g, ''); + const expiry = inlineForm.querySelector('#clover_expiry').value; + const cvv = inlineForm.querySelector('#clover_cvv').value; + const cardholder = inlineForm.querySelector('#clover_cardholder').value; + const cardType = this._detectedCardType !== 'other' + ? this._detectedCardType + : this._getSelectedCardType(inlineForm); + + const [expMonth, expYear] = expiry.split('/').map(Number); + + try { + const result = await rpc('/payment/clover/process_card', { + reference: processingValues.reference, + card_number: cardNumber, + exp_month: expMonth, + exp_year: 2000 + expYear, + cvv: cvv, + cardholder_name: cardholder, + card_type: cardType, + }); + + if (result.error) { + this._displayErrorDialog( + _t("Payment Failed"), + result.error, + ); + this._enableButton(); + return; + } + + window.location.href = processingValues.return_url; + } catch (error) { + this._displayErrorDialog( + _t("Payment Processing Error"), + error.message || _t("An unexpected error occurred."), + ); + this._enableButton(); + } + }, + + async _processTerminalPayment(processingValues, inlineForm) { + const terminalId = inlineForm.querySelector('#clover_terminal_select').value; + const cardType = this._getSelectedCardType(inlineForm); + + try { + const result = await rpc('/payment/clover/send_to_terminal', { + reference: processingValues.reference, + terminal_id: parseInt(terminalId), + card_type: cardType, + }); + + if (result.error) { + this._displayErrorDialog( + _t("Terminal Payment Failed"), + result.error, + ); + this._enableButton(); + return; + } + + this._showTerminalWaitingScreen(processingValues, terminalId); + } catch (error) { + this._displayErrorDialog( + _t("Terminal Error"), + error.message || _t("Failed to send payment to terminal."), + ); + this._enableButton(); + } + }, + + _showTerminalWaitingScreen(processingValues, terminalId) { + const container = document.querySelector('.o_clover_payment_form'); + if (container) { + container.innerHTML = ` +
+
+ Loading... +
+
${_t("Waiting for terminal payment...")}
+

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

+

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

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