changes
This commit is contained in:
17
fusion_clover/__init__.py
Normal file
17
fusion_clover/__init__.py
Normal file
@@ -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})
|
||||||
42
fusion_clover/__manifest__.py
Normal file
42
fusion_clover/__manifest__.py
Normal file
@@ -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',
|
||||||
|
}
|
||||||
BIN
fusion_clover/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clover/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clover/__pycache__/__manifest__.cpython-312.pyc
Normal file
BIN
fusion_clover/__pycache__/__manifest__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clover/__pycache__/const.cpython-312.pyc
Normal file
BIN
fusion_clover/__pycache__/const.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clover/__pycache__/utils.cpython-312.pyc
Normal file
BIN
fusion_clover/__pycache__/utils.cpython-312.pyc
Normal file
Binary file not shown.
85
fusion_clover/const.py
Normal file
85
fusion_clover/const.py
Normal file
@@ -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',
|
||||||
|
}
|
||||||
4
fusion_clover/controllers/__init__.py
Normal file
4
fusion_clover/controllers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import main
|
||||||
|
from . import portal
|
||||||
BIN
fusion_clover/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clover/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clover/controllers/__pycache__/main.cpython-312.pyc
Normal file
BIN
fusion_clover/controllers/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clover/controllers/__pycache__/portal.cpython-312.pyc
Normal file
BIN
fusion_clover/controllers/__pycache__/portal.cpython-312.pyc
Normal file
Binary file not shown.
497
fusion_clover/controllers/main.py
Normal file
497
fusion_clover/controllers/main.py
Normal file
@@ -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.'}
|
||||||
51
fusion_clover/controllers/portal.py
Normal file
51
fusion_clover/controllers/portal.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
97
fusion_clover/data/clover_receipt_email_template.xml
Normal file
97
fusion_clover/data/clover_receipt_email_template.xml
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<record id="mail_template_clover_receipt" model="mail.template">
|
||||||
|
<field name="name">Clover: Payment/Refund Receipt</field>
|
||||||
|
<field name="model_id" ref="payment.model_payment_transaction"/>
|
||||||
|
<field name="subject">{{ object.company_id.name }} - {{ 'Refund Receipt' if (object.operation == 'refund' or object.amount < 0) else 'Payment Receipt' }} {{ object.reference or 'n/a' }}</field>
|
||||||
|
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
|
||||||
|
<field name="email_to">{{ object.partner_id.email }}</field>
|
||||||
|
<field name="report_template_ids"
|
||||||
|
eval="[(4, ref('fusion_clover.action_report_clover_receipt'))]"/>
|
||||||
|
<field name="body_html"><![CDATA[
|
||||||
|
<t t-set="is_refund" t-value="object.operation == 'refund' or object.amount < 0"/>
|
||||||
|
<t t-set="accent" t-value="'#dc3545' if is_refund else '#28a745'"/>
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
|
||||||
|
<div t-attf-style="height:4px;background-color:{{ accent }};"></div>
|
||||||
|
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
|
||||||
|
<p t-attf-style="color:{{ accent }};font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
|
||||||
|
<t t-out="object.company_id.name"/>
|
||||||
|
</p>
|
||||||
|
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">
|
||||||
|
<t t-if="is_refund">Refund Receipt</t>
|
||||||
|
<t t-else="">Payment Receipt</t>
|
||||||
|
</h2>
|
||||||
|
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
|
||||||
|
<t t-if="is_refund">
|
||||||
|
Your refund for <strong style="color:#2d3748;"><t t-out="object.reference"/></strong> has been processed.
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
Your payment for <strong style="color:#2d3748;"><t t-out="object.reference"/></strong> has been processed successfully.
|
||||||
|
</t>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
|
||||||
|
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Transaction Details</td></tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Type</td>
|
||||||
|
<td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #f0f0f0;">
|
||||||
|
<t t-if="is_refund"><strong style="color:#dc3545;">Refund</strong></t>
|
||||||
|
<t t-else=""><strong style="color:#28a745;">Payment</strong></t>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Reference</td>
|
||||||
|
<td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.reference"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td>
|
||||||
|
<td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.create_date.strftime('%B %d, %Y')"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Status</td>
|
||||||
|
<td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #f0f0f0;">
|
||||||
|
<t t-if="is_refund"><strong style="color:#dc3545;">Refunded</strong></t>
|
||||||
|
<t t-else=""><strong style="color:#28a745;">Confirmed</strong></t>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Amount</td>
|
||||||
|
<td t-attf-style="padding:10px 14px;color:{{ accent }};font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;">
|
||||||
|
<t t-if="is_refund">- </t><t t-out="object.currency_id.symbol"/><t t-out="'%.2f' % abs(object.amount)"/> <t t-out="object.currency_id.name"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="padding:10px 14px;border:1px dashed #e2e8f0;border-radius:6px;margin:0 0 24px 0;">
|
||||||
|
<p style="margin:0;font-size:13px;color:#718096;"><strong style="color:#2d3748;">Attached:</strong> Transaction Receipt (PDF)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-attf-style="border-left:3px solid {{ accent }};padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
|
||||||
|
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">
|
||||||
|
<t t-if="is_refund">
|
||||||
|
The refund will appear on your card within 3-5 business days. If you have any questions, please do not hesitate to contact us.
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
Thank you for your payment. If you have any questions about this transaction, please do not hesitate to contact us.
|
||||||
|
</t>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t t-if="object.company_id.phone or object.company_id.email">
|
||||||
|
<p style="margin:0 0 4px 0;font-size:13px;color:#718096;">
|
||||||
|
<t t-if="object.company_id.phone"><t t-out="object.company_id.phone"/></t>
|
||||||
|
<t t-if="object.company_id.phone and object.company_id.email"> | </t>
|
||||||
|
<t t-if="object.company_id.email"><t t-out="object.company_id.email"/></t>
|
||||||
|
</p>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
]]></field>
|
||||||
|
<field name="lang">{{ object.partner_id.lang }}</field>
|
||||||
|
<field name="auto_delete" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
18
fusion_clover/data/clover_surcharge_product.xml
Normal file
18
fusion_clover/data/clover_surcharge_product.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<record id="product_cc_processing_fee" model="product.product">
|
||||||
|
<field name="name">CREDIT CARD PROCESSING FEE</field>
|
||||||
|
<field name="default_code">CLOVER_CC_FEE</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="list_price">0.0</field>
|
||||||
|
<field name="sale_ok" eval="False"/>
|
||||||
|
<field name="purchase_ok" eval="False"/>
|
||||||
|
<field name="taxes_id" eval="[(5, 0, 0)]"/>
|
||||||
|
<field name="supplier_taxes_id" eval="[(5, 0, 0)]"/>
|
||||||
|
<field name="description_sale">Credit card processing surcharge</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
13
fusion_clover/data/payment_provider_data.xml
Normal file
13
fusion_clover/data/payment_provider_data.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<record id="payment_provider_clover" model="payment.provider">
|
||||||
|
<field name="name">Clover</field>
|
||||||
|
<field name="code">clover</field>
|
||||||
|
<field name="inline_form_view_id" ref="inline_form"/>
|
||||||
|
<field name="allow_tokenization">True</field>
|
||||||
|
<field name="state">disabled</field>
|
||||||
|
<field name="image_128" type="base64" file="fusion_clover/static/src/img/clover_logo.png"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
9
fusion_clover/models/__init__.py
Normal file
9
fusion_clover/models/__init__.py
Normal file
@@ -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
|
||||||
BIN
fusion_clover/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clover/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clover/models/__pycache__/account_move.cpython-312.pyc
Normal file
BIN
fusion_clover/models/__pycache__/account_move.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clover/models/__pycache__/clover_terminal.cpython-312.pyc
Normal file
BIN
fusion_clover/models/__pycache__/clover_terminal.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clover/models/__pycache__/payment_token.cpython-312.pyc
Normal file
BIN
fusion_clover/models/__pycache__/payment_token.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
fusion_clover/models/__pycache__/sale_order.cpython-312.pyc
Normal file
BIN
fusion_clover/models/__pycache__/sale_order.cpython-312.pyc
Normal file
Binary file not shown.
200
fusion_clover/models/account_move.py
Normal file
200
fusion_clover/models/account_move.py
Normal file
@@ -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)
|
||||||
282
fusion_clover/models/clover_terminal.py
Normal file
282
fusion_clover/models/clover_terminal.py
Normal file
@@ -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"
|
||||||
608
fusion_clover/models/payment_provider.py
Normal file
608
fusion_clover/models/payment_provider.py
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
18
fusion_clover/models/payment_token.py
Normal file
18
fusion_clover/models/payment_token.py
Normal file
@@ -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',
|
||||||
|
)
|
||||||
663
fusion_clover/models/payment_transaction.py
Normal file
663
fusion_clover/models/payment_transaction.py
Normal file
@@ -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 {}
|
||||||
83
fusion_clover/models/res_config_settings.py
Normal file
83
fusion_clover/models/res_config_settings.py
Normal file
@@ -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')],
|
||||||
|
}
|
||||||
70
fusion_clover/models/sale_order.py
Normal file
70
fusion_clover/models/sale_order.py
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
14
fusion_clover/report/clover_receipt_report.xml
Normal file
14
fusion_clover/report/clover_receipt_report.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="action_report_clover_receipt" model="ir.actions.report">
|
||||||
|
<field name="name">Clover Payment Receipt</field>
|
||||||
|
<field name="model">payment.transaction</field>
|
||||||
|
<field name="report_type">qweb-pdf</field>
|
||||||
|
<field name="report_name">fusion_clover.report_clover_receipt_document</field>
|
||||||
|
<field name="report_file">fusion_clover.report_clover_receipt_document</field>
|
||||||
|
<field name="print_report_name">'Payment_Receipt_%s' % object.reference</field>
|
||||||
|
<field name="binding_type">report</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
251
fusion_clover/report/clover_receipt_templates.xml
Normal file
251
fusion_clover/report/clover_receipt_templates.xml
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<template id="report_clover_receipt_document">
|
||||||
|
<t t-call="web.html_container">
|
||||||
|
<t t-foreach="docs" t-as="tx">
|
||||||
|
<t t-set="receipt" t-value="tx._get_clover_receipt_values()"/>
|
||||||
|
<t t-set="company" t-value="tx.company_id or tx.env.company"/>
|
||||||
|
<t t-set="is_refund" t-value="tx.operation == 'refund' or tx.amount < 0"/>
|
||||||
|
<t t-call="web.external_layout">
|
||||||
|
<div class="page" style="font-family: 'Courier New', Courier, monospace;">
|
||||||
|
|
||||||
|
<div class="text-center mb-3">
|
||||||
|
<h4>
|
||||||
|
<strong t-if="is_refund">REFUND RECEIPT</strong>
|
||||||
|
<strong t-else="">PAYMENT RECEIPT</strong>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transaction details table -->
|
||||||
|
<table class="table table-sm table-borderless" style="font-size: 13px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted" style="width: 40%;">Date</td>
|
||||||
|
<td>
|
||||||
|
<t t-if="receipt.get('created_at')">
|
||||||
|
<t t-esc="receipt['created_at']"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<span t-field="tx.create_date" t-options="{'widget': 'datetime'}"/>
|
||||||
|
</t>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">Reference</td>
|
||||||
|
<td><strong t-field="tx.reference"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr t-if="receipt.get('charge_id')">
|
||||||
|
<td class="text-muted">Charge ID</td>
|
||||||
|
<td style="font-size: 11px;"><t t-esc="receipt['charge_id']"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr t-if="is_refund and tx.source_transaction_id">
|
||||||
|
<td class="text-muted">Original Transaction</td>
|
||||||
|
<td style="font-size: 11px;"><t t-esc="tx.source_transaction_id.reference"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">Type</td>
|
||||||
|
<td>
|
||||||
|
<strong t-if="is_refund" style="color: #dc3545;">REFUND</strong>
|
||||||
|
<strong t-else="" style="color: #28a745;">SALE</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr style="border-top: 1px solid #999;"/>
|
||||||
|
|
||||||
|
<!-- Card info -->
|
||||||
|
<table class="table table-sm table-borderless" style="font-size: 13px;">
|
||||||
|
<tbody>
|
||||||
|
<tr t-if="receipt.get('card_brand')">
|
||||||
|
<td class="text-muted" style="width: 40%;">Card Type</td>
|
||||||
|
<td><t t-esc="receipt['card_brand']"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr t-if="receipt.get('card_last4')">
|
||||||
|
<td class="text-muted">Card Number</td>
|
||||||
|
<td>**** **** **** <t t-esc="receipt['card_last4']"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr t-if="receipt.get('card_holder')">
|
||||||
|
<td class="text-muted">Cardholder</td>
|
||||||
|
<td><t t-esc="receipt['card_holder']"/></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr style="border-top: 1px solid #999;"/>
|
||||||
|
|
||||||
|
<!-- Amounts -->
|
||||||
|
<table class="table table-sm table-borderless" style="font-size: 14px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong t-if="is_refund">REFUND TOTAL</strong>
|
||||||
|
<strong t-else="">TOTAL</strong>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<strong t-attf-style="color: {{ 'dc3545' if is_refund else '000' }};">
|
||||||
|
<t t-if="is_refund">- </t>
|
||||||
|
<t t-esc="receipt.get('currency', 'CAD')"/>
|
||||||
|
<t t-esc="'%.2f' % abs(receipt.get('transaction_amount', 0) or abs(tx.amount))"/>
|
||||||
|
</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr style="border-top: 1px solid #999;"/>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<table class="table table-sm table-borderless" style="font-size: 13px;">
|
||||||
|
<tbody>
|
||||||
|
<tr t-if="receipt.get('status')">
|
||||||
|
<td class="text-muted" style="width: 40%;">Status</td>
|
||||||
|
<td><t t-esc="receipt['status']"/></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr style="border-top: 2px dashed #333;"/>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="text-center mt-3" style="font-size: 12px;">
|
||||||
|
<p class="mb-1">
|
||||||
|
<t t-if="is_refund">Credit Note: </t>
|
||||||
|
<t t-else="">Invoice: </t>
|
||||||
|
<strong t-esc="', '.join(tx.invoice_ids.mapped('name'))" />
|
||||||
|
</p>
|
||||||
|
<p class="mb-1">
|
||||||
|
Customer: <strong t-field="tx.partner_id.name"/>
|
||||||
|
</p>
|
||||||
|
<p class="text-muted mt-3" t-if="is_refund">
|
||||||
|
Refund processed. The amount will be credited to your
|
||||||
|
card within 3-5 business days.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted mt-3" t-else="">
|
||||||
|
Thank you for your payment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Page 2: Original Sale Receipt (only for refunds) -->
|
||||||
|
<t t-if="is_refund and tx.source_transaction_id">
|
||||||
|
<t t-set="src_tx" t-value="tx.source_transaction_id"/>
|
||||||
|
<t t-set="src_receipt" t-value="tx._get_source_receipt_values()"/>
|
||||||
|
<t t-if="src_receipt">
|
||||||
|
<t t-call="web.external_layout">
|
||||||
|
<div class="page" style="font-family: 'Courier New', Courier, monospace;">
|
||||||
|
|
||||||
|
<div class="text-center mb-3">
|
||||||
|
<h4><strong>ORIGINAL SALE RECEIPT</strong></h4>
|
||||||
|
<p class="text-muted" style="font-size: 12px;">
|
||||||
|
Reference for refund <strong t-field="tx.reference"/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transaction details -->
|
||||||
|
<table class="table table-sm table-borderless" style="font-size: 13px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted" style="width: 40%;">Date</td>
|
||||||
|
<td>
|
||||||
|
<t t-if="src_receipt.get('created_at')">
|
||||||
|
<t t-esc="src_receipt['created_at']"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<span t-field="src_tx.create_date" t-options="{'widget': 'datetime'}"/>
|
||||||
|
</t>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">Reference</td>
|
||||||
|
<td><strong t-field="src_tx.reference"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr t-if="src_receipt.get('charge_id')">
|
||||||
|
<td class="text-muted">Charge ID</td>
|
||||||
|
<td style="font-size: 11px;"><t t-esc="src_receipt['charge_id']"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">Type</td>
|
||||||
|
<td><strong style="color: #28a745;">SALE</strong></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr style="border-top: 1px solid #999;"/>
|
||||||
|
|
||||||
|
<!-- Card info -->
|
||||||
|
<table class="table table-sm table-borderless" style="font-size: 13px;">
|
||||||
|
<tbody>
|
||||||
|
<tr t-if="src_receipt.get('card_brand')">
|
||||||
|
<td class="text-muted" style="width: 40%;">Card Type</td>
|
||||||
|
<td><t t-esc="src_receipt['card_brand']"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr t-if="src_receipt.get('card_last4')">
|
||||||
|
<td class="text-muted">Card Number</td>
|
||||||
|
<td>**** **** **** <t t-esc="src_receipt['card_last4']"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr t-if="src_receipt.get('card_holder')">
|
||||||
|
<td class="text-muted">Cardholder</td>
|
||||||
|
<td><t t-esc="src_receipt['card_holder']"/></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr style="border-top: 1px solid #999;"/>
|
||||||
|
|
||||||
|
<!-- Amounts -->
|
||||||
|
<table class="table table-sm table-borderless" style="font-size: 14px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>TOTAL</strong></td>
|
||||||
|
<td class="text-end">
|
||||||
|
<strong>
|
||||||
|
<t t-esc="src_receipt.get('currency', 'CAD')"/>
|
||||||
|
<t t-esc="'%.2f' % abs(src_receipt.get('transaction_amount', 0) or abs(src_tx.amount))"/>
|
||||||
|
</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr style="border-top: 1px solid #999;"/>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<table class="table table-sm table-borderless" style="font-size: 13px;">
|
||||||
|
<tbody>
|
||||||
|
<tr t-if="src_receipt.get('status')">
|
||||||
|
<td class="text-muted" style="width: 40%;">Status</td>
|
||||||
|
<td><t t-esc="src_receipt['status']"/></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr style="border-top: 2px dashed #333;"/>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="text-center mt-3" style="font-size: 12px;">
|
||||||
|
<p class="mb-1">
|
||||||
|
Invoice: <strong t-esc="', '.join(src_tx.invoice_ids.mapped('name'))"/>
|
||||||
|
</p>
|
||||||
|
<p class="mb-1">
|
||||||
|
Customer: <strong t-field="src_tx.partner_id.name"/>
|
||||||
|
</p>
|
||||||
|
<p class="text-muted mt-3">
|
||||||
|
This is the original sale transaction associated
|
||||||
|
with the refund on Page 1.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
10
fusion_clover/security/ir.model.access.csv
Normal file
10
fusion_clover/security/ir.model.access.csv
Normal file
@@ -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
|
||||||
|
29
fusion_clover/security/security.xml
Normal file
29
fusion_clover/security/security.xml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="module_category_fusion_clover" model="ir.module.category">
|
||||||
|
<field name="name">Fusion Clover</field>
|
||||||
|
<field name="sequence">48</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="res_groups_privilege_fusion_clover" model="res.groups.privilege">
|
||||||
|
<field name="name">Fusion Clover</field>
|
||||||
|
<field name="sequence">48</field>
|
||||||
|
<field name="category_id" ref="module_category_fusion_clover"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_fusion_clover_user" model="res.groups">
|
||||||
|
<field name="name">User</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('base.group_user')), (4, ref('account.group_account_invoice'))]"/>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_fusion_clover"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_fusion_clover_admin" model="res.groups">
|
||||||
|
<field name="name">Administrator</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_fusion_clover"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_fusion_clover_user'))]"/>
|
||||||
|
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
BIN
fusion_clover/static/description/icon.png
Normal file
BIN
fusion_clover/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
BIN
fusion_clover/static/src/img/clover_logo.png
Normal file
BIN
fusion_clover/static/src/img/clover_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
468
fusion_clover/static/src/interactions/payment_form.js
Normal file
468
fusion_clover/static/src/interactions/payment_form.js
Normal file
@@ -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 = `
|
||||||
|
<div class="text-center p-4">
|
||||||
|
<div class="spinner-border text-primary mb-3" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<h5>${_t("Waiting for terminal payment...")}</h5>
|
||||||
|
<p class="text-muted">
|
||||||
|
${_t("Please complete the payment on the terminal device.")}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted small" id="clover_terminal_status">
|
||||||
|
${_t("Checking status...")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
177
fusion_clover/utils.py
Normal file
177
fusion_clover/utils.py
Normal file
@@ -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
|
||||||
84
fusion_clover/views/account_move_views.xml
Normal file
84
fusion_clover/views/account_move_views.xml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_move_form_clover_button" model="ir.ui.view">
|
||||||
|
<field name="name">account.move.form.clover.button</field>
|
||||||
|
<field name="model">account.move</field>
|
||||||
|
<field name="inherit_id" ref="account.view_move_form"/>
|
||||||
|
<field name="priority">60</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
|
||||||
|
<!-- Clover Refund smart button on invoices -->
|
||||||
|
<xpath expr="//div[@name='button_box']" position="inside">
|
||||||
|
<button name="action_view_clover_refunds"
|
||||||
|
type="object"
|
||||||
|
class="oe_stat_button"
|
||||||
|
icon="fa-undo"
|
||||||
|
invisible="clover_refund_count == 0">
|
||||||
|
<field name="clover_refund_count" widget="statinfo" string="Clover Refunds"/>
|
||||||
|
</button>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Collect payment button on invoices -->
|
||||||
|
<xpath expr="//button[@id='account_invoice_payment_btn']" position="after">
|
||||||
|
<button name="action_open_clover_payment_wizard"
|
||||||
|
string="Collect Clover Payment"
|
||||||
|
type="object"
|
||||||
|
class="btn-secondary"
|
||||||
|
icon="fa-credit-card"
|
||||||
|
invisible="state != 'posted' or payment_state not in ('not_paid', 'partial') or move_type != 'out_invoice' or not clover_provider_enabled"
|
||||||
|
groups="fusion_clover.group_fusion_clover_user"
|
||||||
|
data-hotkey="p"/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Refund via Clover button on credit notes -->
|
||||||
|
<xpath expr="//button[@id='account_invoice_payment_btn']" position="after">
|
||||||
|
<button name="action_open_clover_refund_wizard"
|
||||||
|
string="Refund via Clover"
|
||||||
|
type="object"
|
||||||
|
class="btn-secondary"
|
||||||
|
icon="fa-undo"
|
||||||
|
invisible="state != 'posted' or payment_state not in ('not_paid', 'partial') or move_type != 'out_refund' or clover_refunded or not clover_provider_enabled"
|
||||||
|
groups="fusion_clover.group_fusion_clover_user"
|
||||||
|
data-hotkey="r"/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Resend Receipt button on invoices -->
|
||||||
|
<xpath expr="//button[@id='account_invoice_payment_btn']" position="after">
|
||||||
|
<button name="action_resend_clover_receipt"
|
||||||
|
string="Resend Receipt"
|
||||||
|
type="object"
|
||||||
|
class="btn-secondary"
|
||||||
|
icon="fa-envelope"
|
||||||
|
invisible="state != 'posted' or move_type != 'out_invoice' or not has_clover_receipt"
|
||||||
|
groups="fusion_clover.group_fusion_clover_user"/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Resend Receipt button on credit notes -->
|
||||||
|
<xpath expr="//button[@id='account_invoice_payment_btn']" position="after">
|
||||||
|
<button name="action_resend_clover_receipt"
|
||||||
|
string="Resend Refund Receipt"
|
||||||
|
type="object"
|
||||||
|
class="btn-secondary"
|
||||||
|
icon="fa-envelope"
|
||||||
|
invisible="state != 'posted' or move_type != 'out_refund' or not clover_refunded"
|
||||||
|
groups="fusion_clover.group_fusion_clover_user"/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Refunded banner on credit notes -->
|
||||||
|
<xpath expr="//header" position="before">
|
||||||
|
<div class="alert alert-info text-center mb-0"
|
||||||
|
role="status"
|
||||||
|
invisible="not clover_refunded">
|
||||||
|
<strong>Refunded via Clover</strong> — This credit note has been
|
||||||
|
refunded to the customer's card through Clover.
|
||||||
|
</div>
|
||||||
|
<field name="clover_refunded" invisible="1"/>
|
||||||
|
<field name="has_clover_receipt" invisible="1"/>
|
||||||
|
<field name="clover_provider_enabled" invisible="1"/>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
109
fusion_clover/views/clover_terminal_views.xml
Normal file
109
fusion_clover/views/clover_terminal_views.xml
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- Terminal form view -->
|
||||||
|
<record id="clover_terminal_form" model="ir.ui.view">
|
||||||
|
<field name="name">clover.terminal.form</field>
|
||||||
|
<field name="model">clover.terminal</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Clover Terminal">
|
||||||
|
<header>
|
||||||
|
<button name="action_refresh_status"
|
||||||
|
string="Ping Device"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
icon="fa-wifi"/>
|
||||||
|
<button name="action_display_welcome"
|
||||||
|
string="Display Welcome"
|
||||||
|
type="object"
|
||||||
|
class="btn-secondary"
|
||||||
|
icon="fa-tv"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box"/>
|
||||||
|
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
|
||||||
|
invisible="active"/>
|
||||||
|
<group>
|
||||||
|
<group string="Device Information">
|
||||||
|
<field name="name" placeholder="e.g. Front Desk, Back Office"/>
|
||||||
|
<field name="clover_device_name" readonly="1"
|
||||||
|
invisible="not clover_device_name"/>
|
||||||
|
<field name="serial_number"/>
|
||||||
|
<field name="device_id" readonly="1"/>
|
||||||
|
<field name="model_name" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Status">
|
||||||
|
<field name="provider_id"/>
|
||||||
|
<field name="status"/>
|
||||||
|
<field name="last_seen"/>
|
||||||
|
<field name="active" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Terminal list view -->
|
||||||
|
<record id="clover_terminal_list" model="ir.ui.view">
|
||||||
|
<field name="name">clover.terminal.list</field>
|
||||||
|
<field name="model">clover.terminal</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Clover Terminals">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="serial_number"/>
|
||||||
|
<field name="model_name"/>
|
||||||
|
<field name="provider_id"/>
|
||||||
|
<field name="status" decoration-success="status == 'online'"
|
||||||
|
decoration-danger="status == 'offline'"
|
||||||
|
decoration-warning="status == 'unknown'"
|
||||||
|
widget="badge"/>
|
||||||
|
<field name="last_seen"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Terminal action -->
|
||||||
|
<record id="action_clover_terminals" model="ir.actions.act_window">
|
||||||
|
<field name="name">Clover Terminals</field>
|
||||||
|
<field name="res_model">clover.terminal</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
No Clover terminals configured yet.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Use the "Sync Terminals" button on the Clover payment provider
|
||||||
|
to automatically fetch devices from your merchant account,
|
||||||
|
or add them manually.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Menu item under Accounting > Configuration -->
|
||||||
|
<menuitem id="menu_clover_terminals"
|
||||||
|
name="Clover Terminals"
|
||||||
|
parent="account.menu_finance_configuration"
|
||||||
|
action="action_clover_terminals"
|
||||||
|
sequence="40"
|
||||||
|
groups="fusion_clover.group_fusion_clover_admin"/>
|
||||||
|
|
||||||
|
<!-- Add Sync Terminals button to payment provider form -->
|
||||||
|
<record id="payment_provider_form_terminal_button" model="ir.ui.view">
|
||||||
|
<field name="name">payment.provider.form.clover.terminal</field>
|
||||||
|
<field name="model">payment.provider</field>
|
||||||
|
<field name="inherit_id" ref="fusion_clover.payment_provider_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//button[@name='action_clover_test_connection']" position="after">
|
||||||
|
<button string="Sync Terminals"
|
||||||
|
type="object"
|
||||||
|
name="action_sync_terminals"
|
||||||
|
class="btn-secondary"
|
||||||
|
icon="fa-refresh"
|
||||||
|
invisible="not clover_merchant_id or (not clover_rest_api_token and not clover_api_key)"
|
||||||
|
colspan="2"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
124
fusion_clover/views/payment_clover_templates.xml
Normal file
124
fusion_clover/views/payment_clover_templates.xml
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- Inline payment form template for Clover -->
|
||||||
|
<template id="inline_form">
|
||||||
|
<t t-set="inline_form_values"
|
||||||
|
t-value="provider_sudo._clover_get_inline_form_values(
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
partner_id,
|
||||||
|
mode == 'validation',
|
||||||
|
payment_method_sudo=pm_sudo,
|
||||||
|
)"
|
||||||
|
/>
|
||||||
|
<div name="o_clover_payment_container"
|
||||||
|
class="o_clover_payment_form"
|
||||||
|
t-att-data-clover-inline-form-values="inline_form_values">
|
||||||
|
|
||||||
|
<!-- Terminal toggle -->
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input type="checkbox" class="form-check-input"
|
||||||
|
id="clover_use_terminal" name="use_terminal"/>
|
||||||
|
<label class="form-check-label" for="clover_use_terminal">
|
||||||
|
<i class="fa fa-credit-card-alt me-1"/>
|
||||||
|
Pay on Clover Terminal
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Terminal select (hidden by default) -->
|
||||||
|
<div class="mb-3" id="clover_terminal_select_wrapper" style="display:none;">
|
||||||
|
<label class="form-label" for="clover_terminal_select">Select Terminal</label>
|
||||||
|
<select class="form-select" id="clover_terminal_select" name="terminal_id">
|
||||||
|
<option value="">Loading terminals...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card number input -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="clover_card_number">Card Number</label>
|
||||||
|
<input type="text" class="form-control"
|
||||||
|
id="clover_card_number"
|
||||||
|
name="card_number"
|
||||||
|
placeholder="4111 1111 1111 1111"
|
||||||
|
maxlength="19"
|
||||||
|
autocomplete="cc-number"
|
||||||
|
required="required"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expiry and CVV row -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="clover_expiry">Expiry Date</label>
|
||||||
|
<input type="text" class="form-control"
|
||||||
|
id="clover_expiry"
|
||||||
|
name="expiry"
|
||||||
|
placeholder="MM/YY"
|
||||||
|
maxlength="5"
|
||||||
|
autocomplete="cc-exp"
|
||||||
|
required="required"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" for="clover_cvv">CVV</label>
|
||||||
|
<input type="password" class="form-control"
|
||||||
|
id="clover_cvv"
|
||||||
|
name="cvv"
|
||||||
|
placeholder="123"
|
||||||
|
maxlength="4"
|
||||||
|
autocomplete="cc-csc"
|
||||||
|
required="required"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cardholder name -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="clover_cardholder">Cardholder Name</label>
|
||||||
|
<input type="text" class="form-control"
|
||||||
|
id="clover_cardholder"
|
||||||
|
name="cardholder_name"
|
||||||
|
placeholder="John Doe"
|
||||||
|
autocomplete="cc-name"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card type selector -->
|
||||||
|
<div class="mb-3 o_clover_card_type_section" style="display:none;">
|
||||||
|
<label class="form-label">Card Type</label>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" name="clover_card_type"
|
||||||
|
id="clover_ct_visa" value="visa"/>
|
||||||
|
<label class="form-check-label" for="clover_ct_visa">Visa</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" name="clover_card_type"
|
||||||
|
id="clover_ct_mc" value="mastercard"/>
|
||||||
|
<label class="form-check-label" for="clover_ct_mc">Mastercard</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" name="clover_card_type"
|
||||||
|
id="clover_ct_amex" value="amex"/>
|
||||||
|
<label class="form-check-label" for="clover_ct_amex">Amex</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" name="clover_card_type"
|
||||||
|
id="clover_ct_other" value="other" checked="checked"/>
|
||||||
|
<label class="form-check-label" for="clover_ct_other">Other</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Surcharge notice -->
|
||||||
|
<div class="mb-3 o_clover_surcharge_notice" style="display:none;">
|
||||||
|
<div class="alert alert-info py-2 mb-0">
|
||||||
|
<small>
|
||||||
|
<i class="fa fa-info-circle me-1"/>
|
||||||
|
<span>A credit card processing fee of </span>
|
||||||
|
<strong id="clover_surcharge_rate">0.00</strong>
|
||||||
|
<span>% (<strong id="clover_surcharge_amount">$0.00</strong>) will be added to your total.</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
54
fusion_clover/views/payment_provider_views.xml
Normal file
54
fusion_clover/views/payment_provider_views.xml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="payment_provider_form" model="ir.ui.view">
|
||||||
|
<field name="name">Clover Provider Form</field>
|
||||||
|
<field name="model">payment.provider</field>
|
||||||
|
<field name="inherit_id" ref="payment.payment_provider_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<group name="provider_credentials" position="inside">
|
||||||
|
<group invisible="code != 'clover'" name="clover_credentials">
|
||||||
|
<separator string="Merchant"/>
|
||||||
|
<field name="clover_merchant_id"
|
||||||
|
required="code == 'clover' and state != 'disabled'"
|
||||||
|
placeholder="e.g. 22701620015"/>
|
||||||
|
<separator string="Ecommerce API Tokens"/>
|
||||||
|
<field name="clover_api_key"
|
||||||
|
required="code == 'clover' and state != 'disabled'"
|
||||||
|
password="True"
|
||||||
|
placeholder="Private token from Ecommerce API Tokens page"/>
|
||||||
|
<field name="clover_public_key"
|
||||||
|
placeholder="Public token from Ecommerce API Tokens page"/>
|
||||||
|
<separator string="REST API / Terminal"/>
|
||||||
|
<field name="clover_rest_api_token"
|
||||||
|
password="True"
|
||||||
|
placeholder="From Clover Dashboard: Setup > API Tokens"/>
|
||||||
|
<separator string="OAuth (Optional)"/>
|
||||||
|
<field name="clover_app_id"
|
||||||
|
placeholder="App ID (for OAuth flow)"/>
|
||||||
|
<label for="clover_app_secret"/>
|
||||||
|
<div class="o_row" col="2">
|
||||||
|
<field name="clover_app_secret" password="True"/>
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group name="provider_credentials" position="after">
|
||||||
|
<group string="Clover Actions"
|
||||||
|
invisible="code != 'clover'" name="clover_actions"
|
||||||
|
col="4">
|
||||||
|
<button string="Test Connection"
|
||||||
|
type="object"
|
||||||
|
name="action_clover_test_connection"
|
||||||
|
class="btn-primary"
|
||||||
|
invisible="not clover_merchant_id or (not clover_api_key and not clover_rest_api_token)"
|
||||||
|
colspan="2"/>
|
||||||
|
</group>
|
||||||
|
<group string="Terminal Settings"
|
||||||
|
invisible="code != 'clover'" name="clover_terminal_settings">
|
||||||
|
<field name="clover_default_terminal_id"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
40
fusion_clover/views/payment_transaction_views.xml
Normal file
40
fusion_clover/views/payment_transaction_views.xml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="payment_transaction_form_inherit_clover" model="ir.ui.view">
|
||||||
|
<field name="name">payment.transaction.form.inherit.clover</field>
|
||||||
|
<field name="model">payment.transaction</field>
|
||||||
|
<field name="inherit_id" ref="payment.payment_transaction_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='provider_reference']" position="after">
|
||||||
|
<field name="clover_charge_id"
|
||||||
|
invisible="provider_code != 'clover'"
|
||||||
|
readonly="1"/>
|
||||||
|
<field name="clover_refund_id"
|
||||||
|
invisible="provider_code != 'clover' or not clover_refund_id"
|
||||||
|
readonly="1"/>
|
||||||
|
<field name="clover_order_id"
|
||||||
|
invisible="provider_code != 'clover' or not clover_order_id"
|
||||||
|
readonly="1"/>
|
||||||
|
<field name="clover_voided"
|
||||||
|
invisible="provider_code != 'clover' or not clover_voided"
|
||||||
|
readonly="1"/>
|
||||||
|
<field name="clover_void_date"
|
||||||
|
invisible="provider_code != 'clover' or not clover_voided"
|
||||||
|
readonly="1"/>
|
||||||
|
</xpath>
|
||||||
|
<!-- Void button on Clover transactions -->
|
||||||
|
<xpath expr="//header" position="inside">
|
||||||
|
<button name="action_clover_void"
|
||||||
|
string="Void Transaction"
|
||||||
|
type="object"
|
||||||
|
class="btn-danger"
|
||||||
|
icon="fa-ban"
|
||||||
|
invisible="provider_code != 'clover' or state != 'done' or clover_voided"
|
||||||
|
confirm="Are you sure you want to void this transaction? This cannot be undone."
|
||||||
|
groups="fusion_clover.group_fusion_clover_admin"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
128
fusion_clover/views/res_config_settings_views.xml
Normal file
128
fusion_clover/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="res_config_settings_view_form_fusion_clover" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.fusion.clover</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//form" position="inside">
|
||||||
|
<app data-string="Fusion Clover" string="Fusion Clover" name="fusion_clover"
|
||||||
|
groups="fusion_clover.group_fusion_clover_admin">
|
||||||
|
|
||||||
|
<h2>Credit Card Surcharge</h2>
|
||||||
|
<div class="row mt-4 o_settings_container">
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_left_pane">
|
||||||
|
<field name="clover_surcharge_enabled"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">Credit Card Processing Fee</span>
|
||||||
|
<div class="text-muted">
|
||||||
|
Automatically add a surcharge line to invoices when collecting payment
|
||||||
|
via Clover. The fee is calculated as a percentage of the invoice total.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4 o_settings_container"
|
||||||
|
invisible="not clover_surcharge_enabled">
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">Surcharge Rates by Card Type</span>
|
||||||
|
<div class="text-muted mb-2">
|
||||||
|
Configure the processing fee percentage for each card brand.
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label for="clover_surcharge_visa_rate"
|
||||||
|
class="col-5 col-form-label">Visa</label>
|
||||||
|
<div class="col-4">
|
||||||
|
<field name="clover_surcharge_visa_rate" class="o_input"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 col-form-label">%</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label for="clover_surcharge_mastercard_rate"
|
||||||
|
class="col-5 col-form-label">Mastercard</label>
|
||||||
|
<div class="col-4">
|
||||||
|
<field name="clover_surcharge_mastercard_rate" class="o_input"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 col-form-label">%</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label for="clover_surcharge_amex_rate"
|
||||||
|
class="col-5 col-form-label">American Express</label>
|
||||||
|
<div class="col-4">
|
||||||
|
<field name="clover_surcharge_amex_rate" class="o_input"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 col-form-label">%</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label for="clover_surcharge_debit_rate"
|
||||||
|
class="col-5 col-form-label">Debit</label>
|
||||||
|
<div class="col-4">
|
||||||
|
<field name="clover_surcharge_debit_rate" class="o_input"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 col-form-label">%</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label for="clover_surcharge_other_rate"
|
||||||
|
class="col-5 col-form-label">Other Cards</label>
|
||||||
|
<div class="col-4">
|
||||||
|
<field name="clover_surcharge_other_rate" class="o_input"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 col-form-label">%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">Surcharge Product</span>
|
||||||
|
<div class="text-muted mb-2">
|
||||||
|
The service product used for the processing fee invoice line.
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<field name="clover_surcharge_product_id"
|
||||||
|
domain="[('type', '=', 'service')]"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Quick Links</h2>
|
||||||
|
<div class="row mt-4 o_settings_container">
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">Payment Provider</span>
|
||||||
|
<div class="text-muted mb-2">
|
||||||
|
Configure your Clover API credentials and merchant ID.
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button name="action_open_clover_provider"
|
||||||
|
type="object"
|
||||||
|
string="Configure Payment Provider"
|
||||||
|
class="btn-link"
|
||||||
|
icon="fa-arrow-right"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</app>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fusion_clover_settings" model="ir.actions.act_window">
|
||||||
|
<field name="name">Fusion Clover Settings</field>
|
||||||
|
<field name="res_model">res.config.settings</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">current</field>
|
||||||
|
<field name="context" eval="{'module': 'fusion_clover'}"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
24
fusion_clover/views/sale_order_views.xml
Normal file
24
fusion_clover/views/sale_order_views.xml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_order_form_clover_button" model="ir.ui.view">
|
||||||
|
<field name="name">sale.order.form.clover.button</field>
|
||||||
|
<field name="model">sale.order</field>
|
||||||
|
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||||
|
<field name="priority">60</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//button[@id='create_invoice']" position="after">
|
||||||
|
<field name="clover_provider_enabled" invisible="1"/>
|
||||||
|
<button name="action_clover_collect_payment"
|
||||||
|
string="Collect Payment"
|
||||||
|
type="object"
|
||||||
|
class="btn-secondary"
|
||||||
|
icon="fa-credit-card"
|
||||||
|
invisible="state not in ('sale', 'done') or not clover_provider_enabled"
|
||||||
|
groups="fusion_clover.group_fusion_clover_user"
|
||||||
|
data-hotkey="p"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
4
fusion_clover/wizard/__init__.py
Normal file
4
fusion_clover/wizard/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import clover_payment_wizard
|
||||||
|
from . import clover_refund_wizard
|
||||||
BIN
fusion_clover/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clover/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
628
fusion_clover/wizard/clover_payment_wizard.py
Normal file
628
fusion_clover/wizard/clover_payment_wizard.py
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
# 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, ValidationError
|
||||||
|
|
||||||
|
from odoo.addons.fusion_clover import utils as clover_utils
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CloverPaymentWizard(models.TransientModel):
|
||||||
|
_name = 'clover.payment.wizard'
|
||||||
|
_description = 'Collect Clover Payment'
|
||||||
|
|
||||||
|
invoice_id = fields.Many2one(
|
||||||
|
'account.move',
|
||||||
|
string="Invoice",
|
||||||
|
required=True,
|
||||||
|
readonly=True,
|
||||||
|
domain="[('move_type', 'in', ('out_invoice', 'out_refund'))]",
|
||||||
|
)
|
||||||
|
partner_id = fields.Many2one(
|
||||||
|
related='invoice_id.partner_id',
|
||||||
|
string="Customer",
|
||||||
|
)
|
||||||
|
amount = fields.Monetary(
|
||||||
|
string="Amount",
|
||||||
|
required=True,
|
||||||
|
currency_field='currency_id',
|
||||||
|
)
|
||||||
|
currency_id = fields.Many2one(
|
||||||
|
'res.currency',
|
||||||
|
string="Currency",
|
||||||
|
required=True,
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
provider_id = fields.Many2one(
|
||||||
|
'payment.provider',
|
||||||
|
string="Clover Provider",
|
||||||
|
required=True,
|
||||||
|
domain="[('code', '=', 'clover'), ('state', '!=', 'disabled')]",
|
||||||
|
)
|
||||||
|
provider_name = fields.Char(
|
||||||
|
related='provider_id.name',
|
||||||
|
string="Clover Provider",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Payment mode (terminal vs manual card) ---
|
||||||
|
payment_mode = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
('terminal', "Terminal"),
|
||||||
|
('card', "Manual Card Entry"),
|
||||||
|
],
|
||||||
|
string="Payment Mode",
|
||||||
|
default='terminal',
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Terminal fields ---
|
||||||
|
terminal_id = fields.Many2one(
|
||||||
|
'clover.terminal',
|
||||||
|
string="Terminal",
|
||||||
|
domain="[('provider_id', '=', provider_id), ('active', '=', True)]",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Card type & surcharge fields ---
|
||||||
|
card_type = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
('visa', "Visa"),
|
||||||
|
('mastercard', "Mastercard"),
|
||||||
|
('amex', "American Express"),
|
||||||
|
('debit', "Debit"),
|
||||||
|
('other', "Other"),
|
||||||
|
],
|
||||||
|
string="Card Type",
|
||||||
|
)
|
||||||
|
surcharge_enabled = fields.Boolean(
|
||||||
|
compute='_compute_surcharge_enabled',
|
||||||
|
)
|
||||||
|
surcharge_rate = fields.Float(
|
||||||
|
string="Surcharge Rate (%)",
|
||||||
|
digits=(5, 2),
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
surcharge_amount = fields.Monetary(
|
||||||
|
string="Surcharge Amount",
|
||||||
|
currency_field='currency_id',
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
surcharge_applied = fields.Boolean(default=False)
|
||||||
|
original_amount = fields.Monetary(
|
||||||
|
string="Invoice Amount",
|
||||||
|
currency_field='currency_id',
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Card entry fields (never stored, transient only) ---
|
||||||
|
card_number = fields.Char(string="Card Number")
|
||||||
|
exp_month = fields.Char(string="Exp. Month", size=2)
|
||||||
|
exp_year = fields.Char(string="Exp. Year", size=4)
|
||||||
|
cvv = fields.Char(string="CVV", size=4)
|
||||||
|
cardholder_name = fields.Char(string="Cardholder Name")
|
||||||
|
|
||||||
|
# --- Status tracking ---
|
||||||
|
state = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
('draft', "Draft"),
|
||||||
|
('waiting', "Waiting for Terminal"),
|
||||||
|
('done', "Payment Collected"),
|
||||||
|
('error', "Error"),
|
||||||
|
],
|
||||||
|
default='draft',
|
||||||
|
)
|
||||||
|
status_message = fields.Text(string="Status", readonly=True)
|
||||||
|
clover_charge_id = fields.Char(readonly=True)
|
||||||
|
clover_payment_id = fields.Char(
|
||||||
|
string="Terminal Payment ID",
|
||||||
|
readonly=True,
|
||||||
|
help="The Clover payment UUID from the terminal response.",
|
||||||
|
)
|
||||||
|
sent_at = fields.Datetime(
|
||||||
|
string="Sent to Terminal At",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
transaction_id = fields.Many2one(
|
||||||
|
'payment.transaction',
|
||||||
|
string="Payment Transaction",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends_context('uid')
|
||||||
|
def _compute_surcharge_enabled(self):
|
||||||
|
enabled = self.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'fusion_clover.surcharge_enabled', 'False',
|
||||||
|
) == 'True'
|
||||||
|
for rec in self:
|
||||||
|
rec.surcharge_enabled = enabled
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _detect_card_brand(card_number):
|
||||||
|
num = (card_number or '').replace(' ', '')
|
||||||
|
if len(num) < 2:
|
||||||
|
return 'other'
|
||||||
|
if num[:2] in ('34', '37'):
|
||||||
|
return 'amex'
|
||||||
|
if num[0] == '4':
|
||||||
|
return 'visa'
|
||||||
|
prefix2 = int(num[:2])
|
||||||
|
if 51 <= prefix2 <= 55:
|
||||||
|
return 'mastercard'
|
||||||
|
if len(num) >= 4:
|
||||||
|
prefix4 = int(num[:4])
|
||||||
|
if 2221 <= prefix4 <= 2720:
|
||||||
|
return 'mastercard'
|
||||||
|
return 'other'
|
||||||
|
|
||||||
|
def _get_surcharge_rate(self, card_type):
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
rate_key = {
|
||||||
|
'visa': 'fusion_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')
|
||||||
|
return float(ICP.get_param(rate_key, '0') or 0)
|
||||||
|
|
||||||
|
@api.onchange('card_number')
|
||||||
|
def _onchange_card_number(self):
|
||||||
|
if self.card_number:
|
||||||
|
self.card_type = self._detect_card_brand(self.card_number)
|
||||||
|
|
||||||
|
@api.onchange('card_type')
|
||||||
|
def _onchange_card_type(self):
|
||||||
|
if not self.card_type or not self.surcharge_enabled:
|
||||||
|
self.surcharge_rate = 0.0
|
||||||
|
self.surcharge_amount = 0.0
|
||||||
|
return
|
||||||
|
rate = self._get_surcharge_rate(self.card_type)
|
||||||
|
base_amount = self.original_amount or self.amount
|
||||||
|
self.surcharge_rate = rate
|
||||||
|
self.surcharge_amount = round(base_amount * rate / 100.0, 2)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def default_get(self, fields_list):
|
||||||
|
res = super().default_get(fields_list)
|
||||||
|
invoice_id = self.env.context.get('active_id')
|
||||||
|
active_model = self.env.context.get('active_model')
|
||||||
|
|
||||||
|
if active_model == 'account.move' and invoice_id:
|
||||||
|
invoice = self.env['account.move'].browse(invoice_id)
|
||||||
|
res['invoice_id'] = invoice.id
|
||||||
|
res['amount'] = invoice.amount_residual
|
||||||
|
res['original_amount'] = invoice.amount_residual
|
||||||
|
res['currency_id'] = invoice.currency_id.id
|
||||||
|
|
||||||
|
provider = self.env['payment.provider'].sudo().search([
|
||||||
|
('code', '=', 'clover'),
|
||||||
|
('state', '!=', 'disabled'),
|
||||||
|
], limit=1)
|
||||||
|
if provider:
|
||||||
|
res['provider_id'] = provider.id
|
||||||
|
if provider.clover_default_terminal_id:
|
||||||
|
res['terminal_id'] = provider.clover_default_terminal_id.id
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
def _get_provider_sudo(self):
|
||||||
|
return self.provider_id.sudo()
|
||||||
|
|
||||||
|
def _apply_surcharge_if_needed(self):
|
||||||
|
"""Add the surcharge invoice line if surcharge is enabled and not yet applied."""
|
||||||
|
if self.surcharge_applied or not self.surcharge_enabled:
|
||||||
|
return
|
||||||
|
if not self.card_type:
|
||||||
|
raise UserError(_("Please select the card type to calculate the surcharge."))
|
||||||
|
|
||||||
|
rate = self._get_surcharge_rate(self.card_type)
|
||||||
|
if rate <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
base_amount = self.original_amount or self.amount
|
||||||
|
fee_amount = round(base_amount * rate / 100.0, 2)
|
||||||
|
if fee_amount <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
product_id = int(ICP.get_param('fusion_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:
|
||||||
|
raise UserError(
|
||||||
|
_("Surcharge product not configured. "
|
||||||
|
"Go to Settings > Fusion Clover to set it up.")
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice = self.invoice_id.sudo()
|
||||||
|
was_posted = invoice.state == 'posted'
|
||||||
|
if was_posted:
|
||||||
|
invoice.button_draft()
|
||||||
|
|
||||||
|
description = _("Credit Card Processing Fee (%(rate).2f%% surcharge)", rate=rate)
|
||||||
|
invoice.write({
|
||||||
|
'invoice_line_ids': [(0, 0, {
|
||||||
|
'product_id': product.id,
|
||||||
|
'name': description,
|
||||||
|
'quantity': 1,
|
||||||
|
'price_unit': fee_amount,
|
||||||
|
'tax_ids': [(5, 0, 0)],
|
||||||
|
})],
|
||||||
|
})
|
||||||
|
|
||||||
|
if was_posted:
|
||||||
|
invoice.action_post()
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'surcharge_applied': True,
|
||||||
|
'surcharge_rate': rate,
|
||||||
|
'surcharge_amount': fee_amount,
|
||||||
|
'amount': invoice.amount_residual,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _remove_surcharge_line(self):
|
||||||
|
"""Remove the surcharge line from the invoice if it was applied."""
|
||||||
|
if not self.surcharge_applied:
|
||||||
|
return
|
||||||
|
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
product_id = int(ICP.get_param('fusion_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:
|
||||||
|
return
|
||||||
|
|
||||||
|
invoice = self.invoice_id.sudo()
|
||||||
|
surcharge_lines = invoice.invoice_line_ids.filtered(
|
||||||
|
lambda l: l.product_id.id == product.id
|
||||||
|
)
|
||||||
|
if not surcharge_lines:
|
||||||
|
self.surcharge_applied = False
|
||||||
|
return
|
||||||
|
|
||||||
|
was_posted = invoice.state == 'posted'
|
||||||
|
if was_posted:
|
||||||
|
invoice.button_draft()
|
||||||
|
|
||||||
|
surcharge_lines.unlink()
|
||||||
|
|
||||||
|
if was_posted:
|
||||||
|
invoice.action_post()
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'surcharge_applied': False,
|
||||||
|
'surcharge_amount': 0.0,
|
||||||
|
'surcharge_rate': 0.0,
|
||||||
|
'amount': invoice.amount_residual,
|
||||||
|
})
|
||||||
|
|
||||||
|
def action_collect_payment(self):
|
||||||
|
"""Process a payment - either via terminal or manual card entry."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.payment_mode == 'terminal':
|
||||||
|
return self._collect_via_terminal()
|
||||||
|
return self._collect_via_card()
|
||||||
|
|
||||||
|
def _collect_via_terminal(self):
|
||||||
|
"""Send payment to Clover terminal via Cloud REST Pay Display API."""
|
||||||
|
self.ensure_one()
|
||||||
|
self._apply_surcharge_if_needed()
|
||||||
|
|
||||||
|
if self.amount <= 0:
|
||||||
|
raise UserError(_("Payment amount must be greater than zero."))
|
||||||
|
if not self.terminal_id:
|
||||||
|
raise UserError(_("Please select a terminal device."))
|
||||||
|
|
||||||
|
self._cleanup_draft_transaction()
|
||||||
|
tx = self._create_payment_transaction()
|
||||||
|
reference = tx.reference
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = self._get_provider_sudo()
|
||||||
|
capture = not provider.capture_manually
|
||||||
|
|
||||||
|
result = self.terminal_id.action_send_payment(
|
||||||
|
amount=self.amount,
|
||||||
|
currency=self.currency_id,
|
||||||
|
reference=reference,
|
||||||
|
capture=capture,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The terminal response may contain the payment immediately
|
||||||
|
# (if the customer already tapped/swiped), or it may be pending.
|
||||||
|
payment = result.get('payment', {})
|
||||||
|
payment_id = payment.get('id', '')
|
||||||
|
|
||||||
|
if payment and payment.get('result') == 'SUCCESS':
|
||||||
|
# Payment completed immediately
|
||||||
|
card_txn = payment.get('cardTransaction', {})
|
||||||
|
tx.write({
|
||||||
|
'clover_charge_id': payment_id,
|
||||||
|
'provider_reference': payment_id,
|
||||||
|
})
|
||||||
|
payment_data = {
|
||||||
|
'reference': reference,
|
||||||
|
'clover_charge_id': payment_id,
|
||||||
|
'clover_status': 'succeeded',
|
||||||
|
'source': {
|
||||||
|
'brand': card_txn.get('cardType', ''),
|
||||||
|
'last4': card_txn.get('last4', ''),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tx._process('clover', payment_data)
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'state': 'done',
|
||||||
|
'status_message': _(
|
||||||
|
"Payment collected successfully. Payment ID: %(pid)s",
|
||||||
|
pid=payment_id,
|
||||||
|
),
|
||||||
|
'clover_payment_id': payment_id,
|
||||||
|
})
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
# Payment sent to terminal, waiting for customer interaction
|
||||||
|
self.write({
|
||||||
|
'state': 'waiting',
|
||||||
|
'status_message': _("Payment sent to terminal. Waiting for customer..."),
|
||||||
|
'sent_at': fields.Datetime.now(),
|
||||||
|
'clover_payment_id': payment_id or '',
|
||||||
|
})
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
except (ValidationError, UserError) as e:
|
||||||
|
self._cleanup_draft_transaction()
|
||||||
|
self._remove_surcharge_line()
|
||||||
|
self.write({
|
||||||
|
'state': 'error',
|
||||||
|
'status_message': str(e),
|
||||||
|
})
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
def action_check_status(self):
|
||||||
|
"""Poll the terminal for payment completion status."""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.terminal_id or not self.transaction_id:
|
||||||
|
raise UserError(_("No terminal or transaction to check."))
|
||||||
|
|
||||||
|
tx = self.transaction_id
|
||||||
|
reference = tx.reference
|
||||||
|
|
||||||
|
result = self.terminal_id.action_check_payment_status(reference)
|
||||||
|
status = result.get('status', 'pending')
|
||||||
|
|
||||||
|
if status in ('CLOSED', 'AUTH', 'AUTHORIZED', 'CAPTURED'):
|
||||||
|
payment_id = result.get('payment_id', '')
|
||||||
|
card_txn = result.get('card_transaction', {})
|
||||||
|
|
||||||
|
tx.write({
|
||||||
|
'clover_charge_id': payment_id or tx.clover_charge_id,
|
||||||
|
'provider_reference': payment_id or tx.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._process('clover', payment_data)
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'state': 'done',
|
||||||
|
'status_message': _(
|
||||||
|
"Payment collected successfully. Payment ID: %(pid)s",
|
||||||
|
pid=payment_id,
|
||||||
|
),
|
||||||
|
'clover_payment_id': payment_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif status in ('DECLINED', 'FAIL', 'FAILED'):
|
||||||
|
tx._set_error(
|
||||||
|
_("Payment was declined by the terminal.")
|
||||||
|
)
|
||||||
|
self._cleanup_draft_transaction()
|
||||||
|
self._remove_surcharge_line()
|
||||||
|
self.write({
|
||||||
|
'state': 'error',
|
||||||
|
'status_message': result.get('message', _("Payment declined at terminal.")),
|
||||||
|
})
|
||||||
|
|
||||||
|
elif status == 'error':
|
||||||
|
self.write({
|
||||||
|
'status_message': result.get('message', _("Error checking status.")),
|
||||||
|
})
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.write({
|
||||||
|
'status_message': result.get('message', _("Still waiting for terminal...")),
|
||||||
|
})
|
||||||
|
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
def _collect_via_card(self):
|
||||||
|
"""Process a manual card entry payment via Clover Ecommerce API."""
|
||||||
|
self.ensure_one()
|
||||||
|
self._apply_surcharge_if_needed()
|
||||||
|
|
||||||
|
if self.amount <= 0:
|
||||||
|
raise UserError(_("Payment amount must be greater than zero."))
|
||||||
|
|
||||||
|
self._validate_card_fields()
|
||||||
|
self._cleanup_draft_transaction()
|
||||||
|
|
||||||
|
tx = self._create_payment_transaction()
|
||||||
|
reference = tx.reference
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = self._get_provider_sudo()
|
||||||
|
capture = not provider.capture_manually
|
||||||
|
|
||||||
|
minor_amount = clover_utils.format_clover_amount(
|
||||||
|
self.amount, self.currency_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'amount': minor_amount,
|
||||||
|
'currency': self.currency_id.name.lower(),
|
||||||
|
'capture': capture,
|
||||||
|
'ecomind': 'moto',
|
||||||
|
'description': reference,
|
||||||
|
'source': self.card_number.replace(' ', ''),
|
||||||
|
'metadata': {
|
||||||
|
'odoo_reference': reference,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = provider._clover_make_ecom_request(
|
||||||
|
'POST', 'v1/charges', payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
charge_id = result.get('id', '')
|
||||||
|
status = result.get('status', '')
|
||||||
|
|
||||||
|
tx.write({
|
||||||
|
'clover_charge_id': charge_id,
|
||||||
|
'provider_reference': charge_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
payment_data = {
|
||||||
|
'reference': reference,
|
||||||
|
'clover_charge_id': charge_id,
|
||||||
|
'clover_status': status,
|
||||||
|
'source': result.get('source', {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == 'failed':
|
||||||
|
tx._set_error(
|
||||||
|
_("Payment was %(status)s by the processor.", status=status)
|
||||||
|
)
|
||||||
|
self._cleanup_draft_transaction()
|
||||||
|
self._remove_surcharge_line()
|
||||||
|
outcome = result.get('outcome', {})
|
||||||
|
decline_msg = outcome.get('type', status)
|
||||||
|
self.write({
|
||||||
|
'state': 'error',
|
||||||
|
'status_message': _(
|
||||||
|
"Payment %(status)s: %(reason)s",
|
||||||
|
status=status,
|
||||||
|
reason=decline_msg,
|
||||||
|
),
|
||||||
|
'clover_charge_id': charge_id,
|
||||||
|
})
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
tx._process('clover', payment_data)
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'state': 'done',
|
||||||
|
'status_message': _(
|
||||||
|
"Payment collected successfully. Charge: %(charge_id)s",
|
||||||
|
charge_id=charge_id,
|
||||||
|
),
|
||||||
|
'clover_charge_id': charge_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
except (ValidationError, UserError) as e:
|
||||||
|
self._cleanup_draft_transaction()
|
||||||
|
self._remove_surcharge_line()
|
||||||
|
self.write({
|
||||||
|
'state': 'error',
|
||||||
|
'status_message': str(e),
|
||||||
|
})
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
def action_send_receipt(self):
|
||||||
|
"""Email the payment receipt to the customer and close the wizard."""
|
||||||
|
self.ensure_one()
|
||||||
|
tx = self.transaction_id
|
||||||
|
if not tx:
|
||||||
|
raise UserError(_("No payment transaction found."))
|
||||||
|
|
||||||
|
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."))
|
||||||
|
|
||||||
|
template.send_mail(tx.id, force_send=True)
|
||||||
|
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
|
def action_cancel_payment(self):
|
||||||
|
"""Cancel the payment and clean up the draft transaction."""
|
||||||
|
self.ensure_one()
|
||||||
|
self._cleanup_draft_transaction()
|
||||||
|
self._remove_surcharge_line()
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
|
def _cleanup_draft_transaction(self):
|
||||||
|
"""Remove the draft payment transaction created by this wizard."""
|
||||||
|
if not self.transaction_id:
|
||||||
|
return
|
||||||
|
tx = self.transaction_id.sudo()
|
||||||
|
if tx.state == 'draft':
|
||||||
|
tx.invoice_ids = [(5,)]
|
||||||
|
tx.unlink()
|
||||||
|
self.transaction_id = False
|
||||||
|
|
||||||
|
# === HELPERS === #
|
||||||
|
|
||||||
|
def _validate_card_fields(self):
|
||||||
|
"""Validate that card entry fields are properly filled."""
|
||||||
|
if not self.card_number or len(self.card_number.replace(' ', '')) < 13:
|
||||||
|
raise UserError(_("Please enter a valid card number."))
|
||||||
|
if not self.exp_month or not self.exp_month.isdigit():
|
||||||
|
raise UserError(_("Please enter a valid expiry month (01-12)."))
|
||||||
|
if not self.exp_year or not self.exp_year.isdigit() or len(self.exp_year) < 2:
|
||||||
|
raise UserError(_("Please enter a valid expiry year."))
|
||||||
|
if not self.cvv or not self.cvv.isdigit():
|
||||||
|
raise UserError(_("Please enter the CVV."))
|
||||||
|
|
||||||
|
def _create_payment_transaction(self):
|
||||||
|
"""Create a payment.transaction linked to the invoice."""
|
||||||
|
PaymentMethod = self.env['payment.method'].sudo().with_context(active_test=False)
|
||||||
|
payment_method = PaymentMethod.search(
|
||||||
|
[('code', '=', 'card')], limit=1,
|
||||||
|
)
|
||||||
|
if not payment_method:
|
||||||
|
payment_method = PaymentMethod.search(
|
||||||
|
[('code', 'in', ('visa', 'mastercard'))], limit=1,
|
||||||
|
)
|
||||||
|
if not payment_method:
|
||||||
|
raise UserError(
|
||||||
|
_("No card payment method found. Please configure one "
|
||||||
|
"in Settings > Payment Methods.")
|
||||||
|
)
|
||||||
|
|
||||||
|
tx_values = {
|
||||||
|
'provider_id': self.provider_id.id,
|
||||||
|
'payment_method_id': payment_method.id,
|
||||||
|
'amount': self.amount,
|
||||||
|
'currency_id': self.currency_id.id,
|
||||||
|
'partner_id': self.partner_id.id,
|
||||||
|
'operation': 'offline',
|
||||||
|
'invoice_ids': [(4, self.invoice_id.id)],
|
||||||
|
}
|
||||||
|
tx = self.env['payment.transaction'].sudo().create(tx_values)
|
||||||
|
self.transaction_id = tx
|
||||||
|
return tx
|
||||||
|
|
||||||
|
def _reopen_wizard(self):
|
||||||
|
"""Return an action that re-opens this wizard record (keeps state)."""
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _("Collect Clover Payment"),
|
||||||
|
'res_model': self._name,
|
||||||
|
'res_id': self.id,
|
||||||
|
'views': [(False, 'form')],
|
||||||
|
'target': 'new',
|
||||||
|
}
|
||||||
157
fusion_clover/wizard/clover_payment_wizard_views.xml
Normal file
157
fusion_clover/wizard/clover_payment_wizard_views.xml
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="clover_payment_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">clover.payment.wizard.form</field>
|
||||||
|
<field name="model">clover.payment.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Collect Clover Payment">
|
||||||
|
<field name="state" invisible="1"/>
|
||||||
|
<field name="clover_payment_id" invisible="1"/>
|
||||||
|
<field name="provider_id" invisible="1"/>
|
||||||
|
<field name="surcharge_enabled" invisible="1"/>
|
||||||
|
<field name="surcharge_applied" invisible="1"/>
|
||||||
|
<field name="original_amount" invisible="1"/>
|
||||||
|
|
||||||
|
<!-- Status banner for waiting / done / error -->
|
||||||
|
<div class="alert alert-info" role="alert"
|
||||||
|
invisible="state != 'waiting'">
|
||||||
|
<strong>Waiting for terminal...</strong>
|
||||||
|
<field name="status_message" nolabel="1"/>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-success" role="alert"
|
||||||
|
invisible="state != 'done'">
|
||||||
|
<strong>Payment Collected</strong>
|
||||||
|
<br/>
|
||||||
|
<field name="status_message" nolabel="1"/>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger" role="alert"
|
||||||
|
invisible="state != 'error'">
|
||||||
|
<strong>Error</strong>
|
||||||
|
<br/>
|
||||||
|
<field name="status_message" nolabel="1"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group invisible="state == 'done'">
|
||||||
|
<group string="Payment Details">
|
||||||
|
<field name="invoice_id"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="amount"/>
|
||||||
|
<field name="currency_id"/>
|
||||||
|
<field name="provider_name"/>
|
||||||
|
</group>
|
||||||
|
<group string="Payment Mode"
|
||||||
|
invisible="state not in ('draft', 'error')">
|
||||||
|
<field name="payment_mode" widget="radio"
|
||||||
|
readonly="state not in ('draft', 'error')"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- Card Type & Surcharge section -->
|
||||||
|
<group string="Card Type & Surcharge"
|
||||||
|
invisible="state == 'done' or not surcharge_enabled">
|
||||||
|
<group>
|
||||||
|
<field name="card_type"
|
||||||
|
widget="radio"
|
||||||
|
required="surcharge_enabled and state in ('draft', 'error')"
|
||||||
|
readonly="state not in ('draft', 'error')"/>
|
||||||
|
</group>
|
||||||
|
<group invisible="not card_type">
|
||||||
|
<field name="surcharge_rate" string="Rate (%)"/>
|
||||||
|
<field name="surcharge_amount"/>
|
||||||
|
<div class="text-muted" colspan="2"
|
||||||
|
invisible="surcharge_amount == 0">
|
||||||
|
A surcharge line will be added to the invoice before payment.
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- Terminal section -->
|
||||||
|
<group string="Terminal"
|
||||||
|
invisible="payment_mode != 'terminal' or state == 'done'">
|
||||||
|
<field name="terminal_id"
|
||||||
|
required="payment_mode == 'terminal' and state in ('draft', 'error')"
|
||||||
|
readonly="state == 'waiting'"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- Card entry section -->
|
||||||
|
<group string="Card Details"
|
||||||
|
invisible="payment_mode != 'card' or state == 'done'">
|
||||||
|
<group>
|
||||||
|
<field name="card_number"
|
||||||
|
placeholder="4111 1111 1111 1111"
|
||||||
|
required="payment_mode == 'card' and state in ('draft', 'error')"
|
||||||
|
password="True"/>
|
||||||
|
<field name="cardholder_name"
|
||||||
|
placeholder="Name on card"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="exp_month"
|
||||||
|
placeholder="MM"
|
||||||
|
required="payment_mode == 'card' and state in ('draft', 'error')"/>
|
||||||
|
<field name="exp_year"
|
||||||
|
placeholder="YYYY"
|
||||||
|
required="payment_mode == 'card' and state in ('draft', 'error')"/>
|
||||||
|
<field name="cvv"
|
||||||
|
placeholder="123"
|
||||||
|
required="payment_mode == 'card' and state in ('draft', 'error')"
|
||||||
|
password="True"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<!-- Draft / Error state: show action buttons -->
|
||||||
|
<button string="Send to Terminal"
|
||||||
|
name="action_collect_payment"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
invisible="payment_mode != 'terminal' or state not in ('draft', 'error')"
|
||||||
|
data-hotkey="q"/>
|
||||||
|
<button string="Collect Payment"
|
||||||
|
name="action_collect_payment"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
invisible="payment_mode != 'card' or state not in ('draft', 'error')"
|
||||||
|
data-hotkey="q"/>
|
||||||
|
|
||||||
|
<!-- Waiting state: check status + cancel -->
|
||||||
|
<button string="Check Status"
|
||||||
|
name="action_check_status"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
invisible="state != 'waiting'"
|
||||||
|
data-hotkey="q"/>
|
||||||
|
<button string="Cancel Payment"
|
||||||
|
name="action_cancel_payment"
|
||||||
|
type="object"
|
||||||
|
class="btn-secondary"
|
||||||
|
invisible="state not in ('waiting', 'error')"
|
||||||
|
data-hotkey="x"/>
|
||||||
|
|
||||||
|
<!-- Done state: send receipt + close -->
|
||||||
|
<button string="Send Receipt"
|
||||||
|
name="action_send_receipt"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
icon="fa-envelope"
|
||||||
|
invisible="state != 'done'"
|
||||||
|
data-hotkey="s"/>
|
||||||
|
<button string="Close"
|
||||||
|
class="btn-secondary"
|
||||||
|
special="cancel"
|
||||||
|
invisible="state != 'done'"
|
||||||
|
data-hotkey="x"/>
|
||||||
|
|
||||||
|
<!-- Draft state: cancel cleans up -->
|
||||||
|
<button string="Cancel"
|
||||||
|
name="action_cancel_payment"
|
||||||
|
type="object"
|
||||||
|
class="btn-secondary"
|
||||||
|
invisible="state != 'draft'"
|
||||||
|
data-hotkey="x"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
476
fusion_clover/wizard/clover_refund_wizard.py
Normal file
476
fusion_clover/wizard/clover_refund_wizard.py
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
# 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 const
|
||||||
|
from odoo.addons.fusion_clover import utils as clover_utils
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CloverRefundWizard(models.TransientModel):
|
||||||
|
_name = 'clover.refund.wizard'
|
||||||
|
_description = 'Refund via Clover'
|
||||||
|
|
||||||
|
credit_note_id = fields.Many2one(
|
||||||
|
'account.move',
|
||||||
|
string="Credit Note",
|
||||||
|
required=True,
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
original_invoice_id = fields.Many2one(
|
||||||
|
'account.move',
|
||||||
|
string="Original Invoice",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
partner_id = fields.Many2one(
|
||||||
|
related='credit_note_id.partner_id',
|
||||||
|
string="Customer",
|
||||||
|
)
|
||||||
|
amount = fields.Monetary(
|
||||||
|
string="Refund Amount",
|
||||||
|
required=True,
|
||||||
|
currency_field='currency_id',
|
||||||
|
)
|
||||||
|
currency_id = fields.Many2one(
|
||||||
|
'res.currency',
|
||||||
|
string="Currency",
|
||||||
|
required=True,
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
provider_id = fields.Many2one(
|
||||||
|
'payment.provider',
|
||||||
|
string="Clover Provider",
|
||||||
|
required=True,
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
provider_name = fields.Char(
|
||||||
|
related='provider_id.name',
|
||||||
|
string="Clover Provider",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
original_transaction_id = fields.Many2one(
|
||||||
|
'payment.transaction',
|
||||||
|
string="Original Transaction",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
original_clover_charge_id = fields.Char(
|
||||||
|
string="Clover Charge ID",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
card_info = fields.Char(
|
||||||
|
string="Card Used",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Transaction age & refund method ---
|
||||||
|
transaction_age_days = fields.Integer(
|
||||||
|
string="Transaction Age (days)",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
refund_type = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
('referenced', "Referenced Refund"),
|
||||||
|
('non_referenced', "Non-Referenced Credit"),
|
||||||
|
],
|
||||||
|
string="Refund Method",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
refund_type_note = fields.Text(
|
||||||
|
string="Note",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
terminal_id = fields.Many2one(
|
||||||
|
'clover.terminal',
|
||||||
|
string="Terminal",
|
||||||
|
domain="[('provider_id', '=', provider_id), ('active', '=', True)]",
|
||||||
|
help="Optional: select a terminal if the customer's card is present. "
|
||||||
|
"Leave empty to issue a non-referenced credit via the Ecommerce API.",
|
||||||
|
)
|
||||||
|
|
||||||
|
refund_transaction_id = fields.Many2one(
|
||||||
|
'payment.transaction',
|
||||||
|
string="Refund Transaction",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
state = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
('confirm', "Confirm"),
|
||||||
|
('done', "Refunded"),
|
||||||
|
('error', "Error"),
|
||||||
|
],
|
||||||
|
default='confirm',
|
||||||
|
)
|
||||||
|
status_message = fields.Text(string="Status", readonly=True)
|
||||||
|
|
||||||
|
def _get_provider_sudo(self):
|
||||||
|
return self.provider_id.sudo()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def default_get(self, fields_list):
|
||||||
|
res = super().default_get(fields_list)
|
||||||
|
credit_note_id = self.env.context.get('active_id')
|
||||||
|
active_model = self.env.context.get('active_model')
|
||||||
|
|
||||||
|
if active_model != 'account.move' or not credit_note_id:
|
||||||
|
return res
|
||||||
|
|
||||||
|
credit_note = self.env['account.move'].browse(credit_note_id)
|
||||||
|
res['credit_note_id'] = credit_note.id
|
||||||
|
res['amount'] = abs(credit_note.amount_residual) or abs(credit_note.amount_total)
|
||||||
|
res['currency_id'] = credit_note.currency_id.id
|
||||||
|
|
||||||
|
orig_tx = credit_note._get_original_clover_transaction()
|
||||||
|
if not orig_tx:
|
||||||
|
raise UserError(_(
|
||||||
|
"No Clover payment transaction found for the original invoice. "
|
||||||
|
"This credit note cannot be refunded via Clover."
|
||||||
|
))
|
||||||
|
|
||||||
|
res['original_transaction_id'] = orig_tx.id
|
||||||
|
res['provider_id'] = orig_tx.provider_id.id
|
||||||
|
res['original_invoice_id'] = credit_note.reversed_entry_id.id
|
||||||
|
res['original_clover_charge_id'] = orig_tx.clover_charge_id
|
||||||
|
|
||||||
|
# Pre-select default terminal
|
||||||
|
provider = orig_tx.provider_id.sudo()
|
||||||
|
if provider.clover_default_terminal_id:
|
||||||
|
res['terminal_id'] = provider.clover_default_terminal_id.id
|
||||||
|
|
||||||
|
# Transaction age & refund method
|
||||||
|
age_days = 0
|
||||||
|
if orig_tx.create_date:
|
||||||
|
age_days = (fields.Datetime.now() - orig_tx.create_date).days
|
||||||
|
res['transaction_age_days'] = age_days
|
||||||
|
|
||||||
|
if age_days > const.REFERENCED_REFUND_LIMIT_DAYS:
|
||||||
|
res['refund_type'] = 'non_referenced'
|
||||||
|
res['refund_type_note'] = _(
|
||||||
|
"This transaction is %(days)s days old (limit is %(limit)s "
|
||||||
|
"days). A non-referenced credit will be issued. This "
|
||||||
|
"requires the customer's card to be present on the terminal.",
|
||||||
|
days=age_days,
|
||||||
|
limit=const.REFERENCED_REFUND_LIMIT_DAYS,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
res['refund_type'] = 'referenced'
|
||||||
|
res['refund_type_note'] = _(
|
||||||
|
"This transaction is %(days)s days old (within the %(limit)s-day "
|
||||||
|
"limit). A referenced refund will be issued back to the "
|
||||||
|
"original card automatically.",
|
||||||
|
days=age_days,
|
||||||
|
limit=const.REFERENCED_REFUND_LIMIT_DAYS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Card info from receipt data
|
||||||
|
receipt_data = orig_tx.clover_receipt_data
|
||||||
|
if receipt_data:
|
||||||
|
try:
|
||||||
|
data = json.loads(receipt_data)
|
||||||
|
card_brand = data.get('card_brand', '')
|
||||||
|
card_last4 = data.get('card_last4', '')
|
||||||
|
if card_brand or card_last4:
|
||||||
|
res['card_info'] = f"{card_brand} ****{card_last4}"
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
def action_process_refund(self):
|
||||||
|
"""Dispatch to referenced refund or non-referenced credit."""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if self.amount <= 0:
|
||||||
|
raise UserError(_("Refund amount must be greater than zero."))
|
||||||
|
|
||||||
|
orig_tx = self.original_transaction_id
|
||||||
|
if orig_tx.clover_voided:
|
||||||
|
raise UserError(_(
|
||||||
|
"This transaction was already voided on %(date)s. "
|
||||||
|
"A voided transaction cannot also be refunded — the charge "
|
||||||
|
"was already reversed before settlement.",
|
||||||
|
date=orig_tx.clover_void_date,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Pre-refund verification on Clover
|
||||||
|
self._verify_not_already_reversed()
|
||||||
|
|
||||||
|
if self.refund_type == 'non_referenced':
|
||||||
|
return self._process_non_referenced_credit()
|
||||||
|
return self._process_referenced_refund()
|
||||||
|
|
||||||
|
def _verify_not_already_reversed(self):
|
||||||
|
"""Check on Clover that the charge hasn't been fully refunded already."""
|
||||||
|
orig_tx = self.original_transaction_id
|
||||||
|
charge_id = orig_tx.clover_charge_id or orig_tx.provider_reference
|
||||||
|
if not charge_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
provider = self._get_provider_sudo()
|
||||||
|
try:
|
||||||
|
provider._clover_verify_charge_not_reversed(charge_id)
|
||||||
|
except (UserError, ValidationError):
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
_logger.debug("Could not verify charge %s before refund", charge_id)
|
||||||
|
|
||||||
|
def _process_referenced_refund(self):
|
||||||
|
"""Send a referenced refund via Clover Ecommerce API."""
|
||||||
|
orig_tx = self.original_transaction_id
|
||||||
|
charge_id = orig_tx.clover_charge_id or orig_tx.provider_reference
|
||||||
|
provider = self._get_provider_sudo()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = provider._clover_create_refund(
|
||||||
|
charge_id=charge_id,
|
||||||
|
amount=self.amount,
|
||||||
|
currency=self.currency_id,
|
||||||
|
reason=f'Refund for {orig_tx.reference} via {self.credit_note_id.name}',
|
||||||
|
)
|
||||||
|
except (ValidationError, UserError) as e:
|
||||||
|
self.write({
|
||||||
|
'state': 'error',
|
||||||
|
'status_message': str(e),
|
||||||
|
})
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
refund_status = result.get('status', '')
|
||||||
|
refund_id = result.get('id', '')
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
"Clover refund response: status=%s, id=%s",
|
||||||
|
refund_status, refund_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if refund_status == 'failed':
|
||||||
|
self.write({
|
||||||
|
'state': 'error',
|
||||||
|
'status_message': _(
|
||||||
|
"Refund declined by the payment processor. "
|
||||||
|
"Status: %(status)s. Please try again or contact support.",
|
||||||
|
status=refund_status,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
refund_tx = self._create_refund_transaction(orig_tx, refund_id, refund_status)
|
||||||
|
self.refund_transaction_id = refund_tx
|
||||||
|
|
||||||
|
self.credit_note_id.sudo().clover_refunded = True
|
||||||
|
|
||||||
|
self.credit_note_id.sudo().message_post(
|
||||||
|
body=_(
|
||||||
|
"Refund processed via Clover. Amount: %(amount)s %(currency)s. "
|
||||||
|
"Clover Refund ID: %(refund_id)s.",
|
||||||
|
amount=self.amount,
|
||||||
|
currency=self.currency_id.name,
|
||||||
|
refund_id=refund_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'state': 'done',
|
||||||
|
'status_message': _(
|
||||||
|
"Refund of %(amount)s %(currency)s processed successfully. "
|
||||||
|
"The refund will appear on the customer's card within "
|
||||||
|
"3-5 business days.",
|
||||||
|
amount=self.amount,
|
||||||
|
currency=self.currency_id.name,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
def _process_non_referenced_credit(self):
|
||||||
|
"""Issue a non-referenced credit (manual refund).
|
||||||
|
|
||||||
|
Two paths:
|
||||||
|
1. If a terminal is selected → send to terminal via Cloud Pay Display
|
||||||
|
(card-present, customer taps/inserts card on device).
|
||||||
|
2. If no terminal → use Clover Ecommerce ``POST /v1/credits``
|
||||||
|
(card-not-present, Clover issues credit to original card on file).
|
||||||
|
|
||||||
|
Note: ``POST /v1/credits`` may not be enabled for all merchants.
|
||||||
|
"""
|
||||||
|
provider = self._get_provider_sudo()
|
||||||
|
orig_tx = self.original_transaction_id
|
||||||
|
|
||||||
|
if self.terminal_id:
|
||||||
|
return self._non_referenced_credit_via_terminal(provider, orig_tx)
|
||||||
|
return self._non_referenced_credit_via_api(provider, orig_tx)
|
||||||
|
|
||||||
|
def _non_referenced_credit_via_api(self, provider, orig_tx):
|
||||||
|
"""Issue a non-referenced credit via Clover Ecommerce API."""
|
||||||
|
description = (
|
||||||
|
f"Non-referenced credit for {orig_tx.reference} "
|
||||||
|
f"via {self.credit_note_id.name}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = provider._clover_create_credit(
|
||||||
|
amount=self.amount,
|
||||||
|
currency=self.currency_id,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
except (ValidationError, UserError) as e:
|
||||||
|
self.write({
|
||||||
|
'state': 'error',
|
||||||
|
'status_message': _(
|
||||||
|
"%(error)s\n\nIf non-referenced credits are not enabled "
|
||||||
|
"for this merchant, select a terminal and ask the customer "
|
||||||
|
"to present their card on the device.",
|
||||||
|
error=str(e),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
credit_id = result.get('id', '')
|
||||||
|
credit_status = result.get('status', 'succeeded')
|
||||||
|
|
||||||
|
refund_tx = self._create_refund_transaction(orig_tx, credit_id, credit_status)
|
||||||
|
self.refund_transaction_id = refund_tx
|
||||||
|
self.credit_note_id.sudo().clover_refunded = True
|
||||||
|
|
||||||
|
self.credit_note_id.sudo().message_post(
|
||||||
|
body=_(
|
||||||
|
"Non-referenced credit issued via Clover. "
|
||||||
|
"Amount: %(amount)s %(currency)s. "
|
||||||
|
"Clover Credit ID: %(credit_id)s.",
|
||||||
|
amount=self.amount,
|
||||||
|
currency=self.currency_id.name,
|
||||||
|
credit_id=credit_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'state': 'done',
|
||||||
|
'status_message': _(
|
||||||
|
"Non-referenced credit of %(amount)s %(currency)s issued "
|
||||||
|
"successfully. The credit will appear on the customer's "
|
||||||
|
"card within 3-5 business days.",
|
||||||
|
amount=self.amount,
|
||||||
|
currency=self.currency_id.name,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
def _non_referenced_credit_via_terminal(self, provider, orig_tx):
|
||||||
|
"""Send a non-referenced credit to the terminal (card-present)."""
|
||||||
|
minor_amount = clover_utils.format_clover_amount(
|
||||||
|
self.amount, self.currency_id,
|
||||||
|
)
|
||||||
|
reference = f"NRC-{self.credit_note_id.name}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'amount': minor_amount,
|
||||||
|
'externalPaymentId': reference,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider._clover_terminal_request(
|
||||||
|
'POST', 'payments',
|
||||||
|
serial_number=self.terminal_id.serial_number,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
except (ValidationError, UserError) as e:
|
||||||
|
self.write({
|
||||||
|
'state': 'error',
|
||||||
|
'status_message': str(e),
|
||||||
|
})
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
refund_tx = self._create_refund_transaction(
|
||||||
|
orig_tx, refund_id='', refund_status='PENDING',
|
||||||
|
)
|
||||||
|
self.refund_transaction_id = refund_tx
|
||||||
|
self.credit_note_id.sudo().clover_refunded = True
|
||||||
|
|
||||||
|
self.credit_note_id.sudo().message_post(
|
||||||
|
body=_(
|
||||||
|
"Non-referenced credit sent to terminal '%(terminal)s'. "
|
||||||
|
"Amount: %(amount)s %(currency)s. "
|
||||||
|
"The customer must present their card on the terminal to "
|
||||||
|
"complete the refund.",
|
||||||
|
terminal=self.terminal_id.name,
|
||||||
|
amount=self.amount,
|
||||||
|
currency=self.currency_id.name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'state': 'done',
|
||||||
|
'status_message': _(
|
||||||
|
"Non-referenced credit of %(amount)s %(currency)s sent to "
|
||||||
|
"terminal '%(terminal)s'. Please ask the customer to present "
|
||||||
|
"their card on the terminal to complete the refund.",
|
||||||
|
amount=self.amount,
|
||||||
|
currency=self.currency_id.name,
|
||||||
|
terminal=self.terminal_id.name,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
|
def _create_refund_transaction(self, orig_tx, refund_id, refund_status):
|
||||||
|
"""Create a payment.transaction for the refund."""
|
||||||
|
PaymentMethod = self.env['payment.method'].sudo().with_context(active_test=False)
|
||||||
|
payment_method = PaymentMethod.search(
|
||||||
|
[('code', '=', 'card')], limit=1,
|
||||||
|
) or PaymentMethod.search(
|
||||||
|
[('code', 'in', ('visa', 'mastercard'))], limit=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
refund_tx = self.env['payment.transaction'].sudo().create({
|
||||||
|
'provider_id': self.provider_id.id,
|
||||||
|
'payment_method_id': payment_method.id if payment_method else False,
|
||||||
|
'amount': -self.amount,
|
||||||
|
'currency_id': self.currency_id.id,
|
||||||
|
'partner_id': self.partner_id.id,
|
||||||
|
'operation': 'refund',
|
||||||
|
'source_transaction_id': orig_tx.id,
|
||||||
|
'provider_reference': refund_id or '',
|
||||||
|
'clover_charge_id': orig_tx.clover_charge_id,
|
||||||
|
'clover_refund_id': refund_id or '',
|
||||||
|
'invoice_ids': [(4, self.credit_note_id.id)],
|
||||||
|
})
|
||||||
|
|
||||||
|
if refund_id and refund_status not in ('PENDING',):
|
||||||
|
payment_data = {
|
||||||
|
'reference': refund_tx.reference,
|
||||||
|
'clover_charge_id': orig_tx.clover_charge_id,
|
||||||
|
'clover_refund_id': refund_id,
|
||||||
|
'clover_status': refund_status or 'succeeded',
|
||||||
|
}
|
||||||
|
refund_tx._process('clover', payment_data)
|
||||||
|
|
||||||
|
return refund_tx
|
||||||
|
|
||||||
|
def action_send_receipt(self):
|
||||||
|
"""Email the refund receipt to the customer and close the wizard."""
|
||||||
|
self.ensure_one()
|
||||||
|
tx = self.refund_transaction_id
|
||||||
|
if not tx:
|
||||||
|
raise UserError(_("No refund transaction found."))
|
||||||
|
|
||||||
|
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."))
|
||||||
|
|
||||||
|
template.send_mail(tx.id, force_send=True)
|
||||||
|
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
|
def _reopen_wizard(self):
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _("Refund via Clover"),
|
||||||
|
'res_model': self._name,
|
||||||
|
'res_id': self.id,
|
||||||
|
'views': [(False, 'form')],
|
||||||
|
'target': 'new',
|
||||||
|
}
|
||||||
114
fusion_clover/wizard/clover_refund_wizard_views.xml
Normal file
114
fusion_clover/wizard/clover_refund_wizard_views.xml
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="clover_refund_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">clover.refund.wizard.form</field>
|
||||||
|
<field name="model">clover.refund.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Refund via Clover">
|
||||||
|
|
||||||
|
<!-- Success banner -->
|
||||||
|
<div class="alert alert-success text-center"
|
||||||
|
role="status"
|
||||||
|
invisible="state != 'done'">
|
||||||
|
<strong>Refund Processed Successfully</strong>
|
||||||
|
<p><field name="status_message" nolabel="1" readonly="1"/></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error banner -->
|
||||||
|
<div class="alert alert-danger text-center"
|
||||||
|
role="alert"
|
||||||
|
invisible="state != 'error'">
|
||||||
|
<strong>Refund Failed</strong>
|
||||||
|
<p><field name="status_message" nolabel="1" readonly="1"/></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Non-referenced credit warning -->
|
||||||
|
<div class="alert alert-warning"
|
||||||
|
role="alert"
|
||||||
|
invisible="state != 'confirm' or refund_type != 'non_referenced'">
|
||||||
|
<strong><i class="fa fa-exclamation-triangle"/> Non-Referenced Credit Required</strong>
|
||||||
|
<p><field name="refund_type_note" nolabel="1" readonly="1"/></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Referenced refund info -->
|
||||||
|
<div class="alert alert-info"
|
||||||
|
role="status"
|
||||||
|
invisible="state != 'confirm' or refund_type != 'referenced'">
|
||||||
|
<i class="fa fa-info-circle"/>
|
||||||
|
<field name="refund_type_note" nolabel="1" readonly="1"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group invisible="state == 'done'">
|
||||||
|
<group string="Refund Details">
|
||||||
|
<field name="credit_note_id" readonly="1"/>
|
||||||
|
<field name="original_invoice_id" readonly="1"/>
|
||||||
|
<field name="partner_id" readonly="1"/>
|
||||||
|
<field name="amount" readonly="state != 'confirm'"/>
|
||||||
|
<field name="currency_id" invisible="1"/>
|
||||||
|
<field name="refund_type" invisible="1"/>
|
||||||
|
<field name="transaction_age_days" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Original Payment">
|
||||||
|
<field name="provider_id" invisible="1"/>
|
||||||
|
<field name="provider_name"/>
|
||||||
|
<field name="original_transaction_id" readonly="1"/>
|
||||||
|
<field name="original_clover_charge_id" readonly="1"/>
|
||||||
|
<field name="card_info" readonly="1"
|
||||||
|
invisible="not card_info"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- Terminal selector for non-referenced credits -->
|
||||||
|
<group string="Terminal (Optional)"
|
||||||
|
invisible="state != 'confirm' or refund_type != 'non_referenced'">
|
||||||
|
<field name="terminal_id"
|
||||||
|
options="{'no_create': True}"/>
|
||||||
|
<div colspan="2" class="text-muted small">
|
||||||
|
<i class="fa fa-info-circle"/>
|
||||||
|
Select a terminal if the customer's card is present.
|
||||||
|
Leave empty to issue the credit via the Clover API.
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<!-- Confirm state -->
|
||||||
|
<button string="Process Refund"
|
||||||
|
name="action_process_refund"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
icon="fa-undo"
|
||||||
|
invisible="state != 'confirm'"
|
||||||
|
confirm="Are you sure you want to refund this amount? This cannot be undone."
|
||||||
|
data-hotkey="q"/>
|
||||||
|
<button string="Cancel"
|
||||||
|
class="btn-secondary"
|
||||||
|
special="cancel"
|
||||||
|
invisible="state != 'confirm'"
|
||||||
|
data-hotkey="x"/>
|
||||||
|
<!-- Done state -->
|
||||||
|
<button string="Send Receipt"
|
||||||
|
name="action_send_receipt"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
icon="fa-envelope"
|
||||||
|
invisible="state != 'done'"
|
||||||
|
data-hotkey="s"/>
|
||||||
|
<button string="Close"
|
||||||
|
class="btn-secondary"
|
||||||
|
special="cancel"
|
||||||
|
invisible="state != 'done'"
|
||||||
|
data-hotkey="x"/>
|
||||||
|
<!-- Error state -->
|
||||||
|
<button string="Close"
|
||||||
|
class="btn-primary"
|
||||||
|
special="cancel"
|
||||||
|
invisible="state != 'error'"
|
||||||
|
data-hotkey="x"/>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -15,12 +15,15 @@
|
|||||||
'report/poynt_receipt_report.xml',
|
'report/poynt_receipt_report.xml',
|
||||||
'report/poynt_receipt_templates.xml',
|
'report/poynt_receipt_templates.xml',
|
||||||
|
|
||||||
|
'data/poynt_surcharge_product.xml',
|
||||||
|
|
||||||
'views/payment_provider_views.xml',
|
'views/payment_provider_views.xml',
|
||||||
'views/payment_transaction_views.xml',
|
'views/payment_transaction_views.xml',
|
||||||
'views/payment_poynt_templates.xml',
|
'views/payment_poynt_templates.xml',
|
||||||
'views/poynt_terminal_views.xml',
|
'views/poynt_terminal_views.xml',
|
||||||
'views/account_move_views.xml',
|
'views/account_move_views.xml',
|
||||||
'views/sale_order_views.xml',
|
'views/sale_order_views.xml',
|
||||||
|
'views/res_config_settings_views.xml',
|
||||||
'wizard/poynt_payment_wizard_views.xml',
|
'wizard/poynt_payment_wizard_views.xml',
|
||||||
'wizard/poynt_refund_wizard_views.xml',
|
'wizard/poynt_refund_wizard_views.xml',
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
from . import main
|
from . import main
|
||||||
|
from . import portal
|
||||||
|
|||||||
@@ -19,6 +19,25 @@ from odoo.addons.fusion_poynt import utils as poynt_utils
|
|||||||
_logger = logging.getLogger(__name__)
|
_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 PoyntController(http.Controller):
|
class PoyntController(http.Controller):
|
||||||
_return_url = '/payment/poynt/return'
|
_return_url = '/payment/poynt/return'
|
||||||
_webhook_url = '/payment/poynt/webhook'
|
_webhook_url = '/payment/poynt/webhook'
|
||||||
@@ -344,6 +363,81 @@ class PoyntController(http.Controller):
|
|||||||
|
|
||||||
return request.redirect('/odoo/settings')
|
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.
|
||||||
|
|
||||||
|
Detects the card brand from the number if card_type is not provided,
|
||||||
|
adds a surcharge line to the invoice, and updates the transaction
|
||||||
|
amount to include the fee.
|
||||||
|
|
||||||
|
:param tx_sudo: The sudo payment.transaction record.
|
||||||
|
:param str card_type: The card brand (visa, mastercard, amex, other).
|
||||||
|
:return: The surcharge fee amount, or 0 if not applied.
|
||||||
|
:rtype: float
|
||||||
|
"""
|
||||||
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
if ICP.get_param('fusion_poynt.surcharge_enabled', 'False') != 'True':
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
if not card_type:
|
||||||
|
card_type = 'other'
|
||||||
|
|
||||||
|
rate_key = {
|
||||||
|
'visa': 'fusion_poynt.surcharge_visa_rate',
|
||||||
|
'mastercard': 'fusion_poynt.surcharge_mastercard_rate',
|
||||||
|
'amex': 'fusion_poynt.surcharge_amex_rate',
|
||||||
|
'debit': 'fusion_poynt.surcharge_debit_rate',
|
||||||
|
}.get(card_type, 'fusion_poynt.surcharge_other_rate')
|
||||||
|
|
||||||
|
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_poynt.surcharge_product_id', '0') or 0)
|
||||||
|
product = request.env['product.product'].sudo().browse(product_id).exists()
|
||||||
|
if not product:
|
||||||
|
product = request.env.ref(
|
||||||
|
'fusion_poynt.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
|
||||||
|
|
||||||
# === JSON-RPC ROUTES (called from frontend JS) === #
|
# === JSON-RPC ROUTES (called from frontend JS) === #
|
||||||
|
|
||||||
@http.route('/payment/poynt/terminals', type='jsonrpc', auth='public')
|
@http.route('/payment/poynt/terminals', type='jsonrpc', auth='public')
|
||||||
@@ -372,7 +466,8 @@ class PoyntController(http.Controller):
|
|||||||
@http.route('/payment/poynt/process_card', type='jsonrpc', auth='public')
|
@http.route('/payment/poynt/process_card', type='jsonrpc', auth='public')
|
||||||
def poynt_process_card(self, reference=None, poynt_order_id=None,
|
def poynt_process_card(self, reference=None, poynt_order_id=None,
|
||||||
card_number=None, exp_month=None, exp_year=None,
|
card_number=None, exp_month=None, exp_year=None,
|
||||||
cvv=None, cardholder_name=None, **kwargs):
|
cvv=None, cardholder_name=None, card_type=None,
|
||||||
|
**kwargs):
|
||||||
"""Process a card payment through Poynt Cloud API.
|
"""Process a card payment through Poynt Cloud API.
|
||||||
|
|
||||||
The frontend sends card details which are passed to Poynt for
|
The frontend sends card details which are passed to Poynt for
|
||||||
@@ -393,6 +488,11 @@ class PoyntController(http.Controller):
|
|||||||
return {'error': 'Transaction not found.'}
|
return {'error': 'Transaction not found.'}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if not card_type and card_number:
|
||||||
|
card_type = _detect_card_brand(card_number)
|
||||||
|
|
||||||
|
surcharge_fee = self._apply_portal_surcharge(tx_sudo, card_type)
|
||||||
|
|
||||||
funding_source = {
|
funding_source = {
|
||||||
'type': 'CREDIT_DEBIT',
|
'type': 'CREDIT_DEBIT',
|
||||||
'card': {
|
'card': {
|
||||||
@@ -469,7 +569,7 @@ class PoyntController(http.Controller):
|
|||||||
|
|
||||||
@http.route('/payment/poynt/send_to_terminal', type='jsonrpc', auth='public')
|
@http.route('/payment/poynt/send_to_terminal', type='jsonrpc', auth='public')
|
||||||
def poynt_send_to_terminal(self, reference=None, terminal_id=None,
|
def poynt_send_to_terminal(self, reference=None, terminal_id=None,
|
||||||
poynt_order_id=None, **kwargs):
|
poynt_order_id=None, card_type=None, **kwargs):
|
||||||
"""Send a payment request to a Poynt terminal device.
|
"""Send a payment request to a Poynt terminal device.
|
||||||
|
|
||||||
:return: Dict with success status or error message.
|
:return: Dict with success status or error message.
|
||||||
@@ -491,6 +591,8 @@ class PoyntController(http.Controller):
|
|||||||
return {'error': 'Terminal not found.'}
|
return {'error': 'Terminal not found.'}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
surcharge_fee = self._apply_portal_surcharge(tx_sudo, card_type or 'other')
|
||||||
|
|
||||||
result = terminal.action_send_payment_to_terminal(
|
result = terminal.action_send_payment_to_terminal(
|
||||||
amount=tx_sudo.amount,
|
amount=tx_sudo.amount,
|
||||||
currency=tx_sudo.currency_id,
|
currency=tx_sudo.currency_id,
|
||||||
|
|||||||
60
fusion_poynt/controllers/portal.py
Normal file
60
fusion_poynt/controllers/portal.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# 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 PoyntCustomerPortal(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.
|
||||||
|
|
||||||
|
For confirmed sale orders (state == 'sale') that haven't been fully
|
||||||
|
paid, this automatically sets payment_amount to the remaining balance
|
||||||
|
so that the standard portal "Pay Now" button appears without requiring
|
||||||
|
a separate payment link URL.
|
||||||
|
|
||||||
|
Rental orders are excluded -- their payment flow is managed by
|
||||||
|
fusion_rental.
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
)
|
||||||
18
fusion_poynt/data/poynt_surcharge_product.xml
Normal file
18
fusion_poynt/data/poynt_surcharge_product.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
|
||||||
|
<record id="product_cc_processing_fee" model="product.product">
|
||||||
|
<field name="name">CREDIT CARD PROCESSING FEE</field>
|
||||||
|
<field name="default_code">POYNT_CC_FEE</field>
|
||||||
|
<field name="type">service</field>
|
||||||
|
<field name="list_price">0.0</field>
|
||||||
|
<field name="sale_ok" eval="False"/>
|
||||||
|
<field name="purchase_ok" eval="False"/>
|
||||||
|
<field name="taxes_id" eval="[(5, 0, 0)]"/>
|
||||||
|
<field name="supplier_taxes_id" eval="[(5, 0, 0)]"/>
|
||||||
|
<field name="description_sale">Credit card processing surcharge</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@@ -5,4 +5,5 @@ from . import payment_provider
|
|||||||
from . import payment_token
|
from . import payment_token
|
||||||
from . import payment_transaction
|
from . import payment_transaction
|
||||||
from . import poynt_terminal
|
from . import poynt_terminal
|
||||||
|
from . import res_config_settings
|
||||||
from . import sale_order
|
from . import sale_order
|
||||||
|
|||||||
@@ -347,6 +347,21 @@ class PaymentProvider(models.Model):
|
|||||||
and payment_method_sudo.support_tokenization
|
and payment_method_sudo.support_tokenization
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
surcharge_enabled = ICP.get_param(
|
||||||
|
'fusion_poynt.surcharge_enabled', 'False',
|
||||||
|
) == 'True'
|
||||||
|
if surcharge_enabled:
|
||||||
|
inline_form_values['surcharge'] = {
|
||||||
|
'enabled': True,
|
||||||
|
'visa': float(ICP.get_param('fusion_poynt.surcharge_visa_rate', '0') or 0),
|
||||||
|
'mastercard': float(ICP.get_param('fusion_poynt.surcharge_mastercard_rate', '0') or 0),
|
||||||
|
'amex': float(ICP.get_param('fusion_poynt.surcharge_amex_rate', '0') or 0),
|
||||||
|
'debit': float(ICP.get_param('fusion_poynt.surcharge_debit_rate', '0') or 0),
|
||||||
|
'other': float(ICP.get_param('fusion_poynt.surcharge_other_rate', '0') or 0),
|
||||||
|
}
|
||||||
|
|
||||||
return json.dumps(inline_form_values)
|
return json.dumps(inline_form_values)
|
||||||
|
|
||||||
# === ACTION METHODS === #
|
# === ACTION METHODS === #
|
||||||
|
|||||||
@@ -92,13 +92,20 @@ class PaymentTransaction(models.Model):
|
|||||||
|
|
||||||
poynt_data = self._poynt_create_order_and_authorize()
|
poynt_data = self._poynt_create_order_and_authorize()
|
||||||
if poynt_data:
|
if poynt_data:
|
||||||
|
status = poynt_data.get('status', 'AUTHORIZED')
|
||||||
payment_data = {
|
payment_data = {
|
||||||
'reference': self.reference,
|
'reference': self.reference,
|
||||||
'poynt_order_id': poynt_data.get('order_id'),
|
'poynt_order_id': poynt_data.get('order_id'),
|
||||||
'poynt_transaction_id': poynt_data.get('transaction_id'),
|
'poynt_transaction_id': poynt_data.get('transaction_id'),
|
||||||
'poynt_status': poynt_data.get('status', 'AUTHORIZED'),
|
'poynt_status': status,
|
||||||
'funding_source': poynt_data.get('funding_source', {}),
|
'funding_source': poynt_data.get('funding_source', {}),
|
||||||
}
|
}
|
||||||
|
if status in ('DECLINED', 'FAILED', 'REFUND_FAILED'):
|
||||||
|
self._set_error(
|
||||||
|
_("Payment was %(status)s by the processor.",
|
||||||
|
status=status.lower())
|
||||||
|
)
|
||||||
|
return
|
||||||
self._process('poynt', payment_data)
|
self._process('poynt', payment_data)
|
||||||
|
|
||||||
def _poynt_create_order_and_authorize(self):
|
def _poynt_create_order_and_authorize(self):
|
||||||
@@ -148,6 +155,103 @@ class PaymentTransaction(models.Model):
|
|||||||
self._set_error(str(e))
|
self._set_error(str(e))
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _detect_card_brand_from_details(payment_details):
|
||||||
|
"""Detect card brand from the payment_details string on a token.
|
||||||
|
|
||||||
|
Tokens store details like "VISA ending in 1234" or
|
||||||
|
"AMERICAN_EXPRESS ending in 5678".
|
||||||
|
"""
|
||||||
|
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.
|
||||||
|
|
||||||
|
Checks if surcharge is enabled, detects card brand from the token,
|
||||||
|
adds a surcharge line to the invoice, and updates the transaction
|
||||||
|
amount. Skips rental orders (recurring charges should not get
|
||||||
|
surcharge), invoices with no linked records, or invoices where
|
||||||
|
surcharge is already applied.
|
||||||
|
"""
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
if ICP.get_param('fusion_poynt.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_poynt.surcharge_visa_rate',
|
||||||
|
'mastercard': 'fusion_poynt.surcharge_mastercard_rate',
|
||||||
|
'amex': 'fusion_poynt.surcharge_amex_rate',
|
||||||
|
'debit': 'fusion_poynt.surcharge_debit_rate',
|
||||||
|
}.get(card_type, 'fusion_poynt.surcharge_other_rate')
|
||||||
|
rate = float(ICP.get_param(rate_key, '0') or 0)
|
||||||
|
if rate <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
product_id = int(ICP.get_param('fusion_poynt.surcharge_product_id', '0') or 0)
|
||||||
|
product = self.env['product.product'].sudo().browse(product_id).exists()
|
||||||
|
if not product:
|
||||||
|
product = self.env.ref(
|
||||||
|
'fusion_poynt.product_cc_processing_fee', raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
if not product:
|
||||||
|
_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 _poynt_process_token_payment(self):
|
def _poynt_process_token_payment(self):
|
||||||
"""Process a payment using a stored token (card on file).
|
"""Process a payment using a stored token (card on file).
|
||||||
|
|
||||||
@@ -156,6 +260,8 @@ class PaymentTransaction(models.Model):
|
|||||||
were created before the JWT migration.
|
were created before the JWT migration.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
self._apply_token_surcharge()
|
||||||
|
|
||||||
provider = self._get_provider_sudo()
|
provider = self._get_provider_sudo()
|
||||||
action = 'AUTHORIZE' if provider.capture_manually else 'SALE'
|
action = 'AUTHORIZE' if provider.capture_manually else 'SALE'
|
||||||
payment_jwt = self.token_id.poynt_payment_token
|
payment_jwt = self.token_id.poynt_payment_token
|
||||||
@@ -213,13 +319,28 @@ class PaymentTransaction(models.Model):
|
|||||||
if order_id:
|
if order_id:
|
||||||
self.poynt_order_id = order_id
|
self.poynt_order_id = order_id
|
||||||
|
|
||||||
|
status = txn_result.get('status', '')
|
||||||
payment_data = {
|
payment_data = {
|
||||||
'reference': self.reference,
|
'reference': self.reference,
|
||||||
'poynt_order_id': order_id,
|
'poynt_order_id': order_id,
|
||||||
'poynt_transaction_id': transaction_id,
|
'poynt_transaction_id': transaction_id,
|
||||||
'poynt_status': txn_result.get('status', ''),
|
'poynt_status': status,
|
||||||
'funding_source': txn_result.get('fundingSource', {}),
|
'funding_source': txn_result.get('fundingSource', {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if status in ('DECLINED', 'FAILED', 'REFUND_FAILED'):
|
||||||
|
processor = txn_result.get('processorResponse', {})
|
||||||
|
decline_msg = (
|
||||||
|
processor.get('statusMessage')
|
||||||
|
or processor.get('message')
|
||||||
|
or status.lower()
|
||||||
|
)
|
||||||
|
self._set_error(
|
||||||
|
_("Payment %(status)s: %(reason)s",
|
||||||
|
status=status.lower(), reason=decline_msg)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
self._process('poynt', payment_data)
|
self._process('poynt', payment_data)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
self._set_error(str(e))
|
self._set_error(str(e))
|
||||||
|
|||||||
91
fusion_poynt/models/res_config_settings.py
Normal file
91
fusion_poynt/models/res_config_settings.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# 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'
|
||||||
|
|
||||||
|
poynt_surcharge_enabled = fields.Boolean(
|
||||||
|
string="Enable Credit Card Surcharge",
|
||||||
|
config_parameter='fusion_poynt.surcharge_enabled',
|
||||||
|
)
|
||||||
|
poynt_surcharge_visa_rate = fields.Float(
|
||||||
|
string="Visa Rate (%)",
|
||||||
|
config_parameter='fusion_poynt.surcharge_visa_rate',
|
||||||
|
default=2.5,
|
||||||
|
)
|
||||||
|
poynt_surcharge_mastercard_rate = fields.Float(
|
||||||
|
string="Mastercard Rate (%)",
|
||||||
|
config_parameter='fusion_poynt.surcharge_mastercard_rate',
|
||||||
|
default=2.5,
|
||||||
|
)
|
||||||
|
poynt_surcharge_amex_rate = fields.Float(
|
||||||
|
string="Amex Rate (%)",
|
||||||
|
config_parameter='fusion_poynt.surcharge_amex_rate',
|
||||||
|
default=3.5,
|
||||||
|
)
|
||||||
|
poynt_surcharge_debit_rate = fields.Float(
|
||||||
|
string="Debit Rate (%)",
|
||||||
|
config_parameter='fusion_poynt.surcharge_debit_rate',
|
||||||
|
default=0.0,
|
||||||
|
)
|
||||||
|
poynt_surcharge_other_rate = fields.Float(
|
||||||
|
string="Other Cards Rate (%)",
|
||||||
|
config_parameter='fusion_poynt.surcharge_other_rate',
|
||||||
|
default=2.5,
|
||||||
|
)
|
||||||
|
poynt_surcharge_product_id = fields.Many2one(
|
||||||
|
'product.product',
|
||||||
|
string="Surcharge Product",
|
||||||
|
config_parameter='fusion_poynt.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_poynt.surcharge_product_id', '0') or 0)
|
||||||
|
if product_id and self.env['product.product'].sudo().browse(product_id).exists():
|
||||||
|
res['poynt_surcharge_product_id'] = product_id
|
||||||
|
else:
|
||||||
|
default = self.env.ref('fusion_poynt.product_cc_processing_fee', raise_if_not_found=False)
|
||||||
|
res['poynt_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_poynt.surcharge_product_id',
|
||||||
|
str(self.poynt_surcharge_product_id.id) if self.poynt_surcharge_product_id else '0',
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_open_poynt_provider(self):
|
||||||
|
provider = self.env['payment.provider'].sudo().search(
|
||||||
|
[('code', '=', 'poynt')], 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', '=', 'poynt')],
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_open_poynt_terminals(self):
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'poynt.terminal',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ patch(PaymentForm.prototype, {
|
|||||||
setup() {
|
setup() {
|
||||||
super.setup();
|
super.setup();
|
||||||
this.poyntFormData = {};
|
this.poyntFormData = {};
|
||||||
|
this._detectedCardType = 'other';
|
||||||
|
this._selectedCardType = 'other';
|
||||||
},
|
},
|
||||||
|
|
||||||
// #=== DOM MANIPULATION ===#
|
// #=== DOM MANIPULATION ===#
|
||||||
@@ -42,6 +44,77 @@ patch(PaymentForm.prototype, {
|
|||||||
|
|
||||||
this._setupCardFormatting(poyntContainer);
|
this._setupCardFormatting(poyntContainer);
|
||||||
this._setupTerminalToggle(poyntContainer);
|
this._setupTerminalToggle(poyntContainer);
|
||||||
|
this._setupSurcharge(poyntContainer);
|
||||||
|
},
|
||||||
|
|
||||||
|
_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.poyntFormData.surcharge;
|
||||||
|
if (!surchargeConfig || !surchargeConfig.enabled) return;
|
||||||
|
|
||||||
|
const cardTypeSection = container.querySelector('.o_poynt_card_type_section');
|
||||||
|
const surchargeNotice = container.querySelector('.o_poynt_surcharge_notice');
|
||||||
|
|
||||||
|
if (cardTypeSection) {
|
||||||
|
cardTypeSection.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardTypeRadios = container.querySelectorAll('input[name="poynt_card_type"]');
|
||||||
|
cardTypeRadios.forEach(radio => {
|
||||||
|
radio.addEventListener('change', () => {
|
||||||
|
this._selectedCardType = radio.value;
|
||||||
|
this._updateSurchargeDisplay(container);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this._updateSurchargeDisplay(container);
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateSurchargeDisplay(container) {
|
||||||
|
const surchargeConfig = this.poyntFormData.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.poyntFormData.minor_amount || 0;
|
||||||
|
const currencyName = this.poyntFormData.currency_name || 'CAD';
|
||||||
|
|
||||||
|
const baseAmount = amount / 100;
|
||||||
|
const feeAmount = Math.round(baseAmount * rate) / 100;
|
||||||
|
|
||||||
|
const rateEl = container.querySelector('#poynt_surcharge_rate');
|
||||||
|
const amountEl = container.querySelector('#poynt_surcharge_amount');
|
||||||
|
const noticeEl = container.querySelector('.o_poynt_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="poynt_card_type"][value="${cardType}"]`
|
||||||
|
);
|
||||||
|
if (radioToCheck && !radioToCheck.checked) {
|
||||||
|
radioToCheck.checked = true;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_setupCardFormatting(container) {
|
_setupCardFormatting(container) {
|
||||||
@@ -57,6 +130,17 @@ patch(PaymentForm.prototype, {
|
|||||||
formatted += value[i];
|
formatted += value[i];
|
||||||
}
|
}
|
||||||
e.target.value = formatted;
|
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_poynt_payment_form')
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,11 +312,19 @@ patch(PaymentForm.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_getSelectedCardType(inlineForm) {
|
||||||
|
const checked = inlineForm.querySelector('input[name="poynt_card_type"]:checked');
|
||||||
|
return checked ? checked.value : 'other';
|
||||||
|
},
|
||||||
|
|
||||||
async _processCardPayment(processingValues, inlineForm) {
|
async _processCardPayment(processingValues, inlineForm) {
|
||||||
const cardNumber = inlineForm.querySelector('#poynt_card_number').value.replace(/\D/g, '');
|
const cardNumber = inlineForm.querySelector('#poynt_card_number').value.replace(/\D/g, '');
|
||||||
const expiry = inlineForm.querySelector('#poynt_expiry').value;
|
const expiry = inlineForm.querySelector('#poynt_expiry').value;
|
||||||
const cvv = inlineForm.querySelector('#poynt_cvv').value;
|
const cvv = inlineForm.querySelector('#poynt_cvv').value;
|
||||||
const cardholder = inlineForm.querySelector('#poynt_cardholder').value;
|
const cardholder = inlineForm.querySelector('#poynt_cardholder').value;
|
||||||
|
const cardType = this._detectedCardType !== 'other'
|
||||||
|
? this._detectedCardType
|
||||||
|
: this._getSelectedCardType(inlineForm);
|
||||||
|
|
||||||
const [expMonth, expYear] = expiry.split('/').map(Number);
|
const [expMonth, expYear] = expiry.split('/').map(Number);
|
||||||
|
|
||||||
@@ -245,6 +337,7 @@ patch(PaymentForm.prototype, {
|
|||||||
exp_year: 2000 + expYear,
|
exp_year: 2000 + expYear,
|
||||||
cvv: cvv,
|
cvv: cvv,
|
||||||
cardholder_name: cardholder,
|
cardholder_name: cardholder,
|
||||||
|
card_type: cardType,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
@@ -268,12 +361,14 @@ patch(PaymentForm.prototype, {
|
|||||||
|
|
||||||
async _processTerminalPayment(processingValues, inlineForm) {
|
async _processTerminalPayment(processingValues, inlineForm) {
|
||||||
const terminalId = inlineForm.querySelector('#poynt_terminal_select').value;
|
const terminalId = inlineForm.querySelector('#poynt_terminal_select').value;
|
||||||
|
const cardType = this._getSelectedCardType(inlineForm);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await rpc('/payment/poynt/send_to_terminal', {
|
const result = await rpc('/payment/poynt/send_to_terminal', {
|
||||||
reference: processingValues.reference,
|
reference: processingValues.reference,
|
||||||
terminal_id: parseInt(terminalId),
|
terminal_id: parseInt(terminalId),
|
||||||
poynt_order_id: processingValues.poynt_order_id,
|
poynt_order_id: processingValues.poynt_order_id,
|
||||||
|
card_type: cardType,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
|||||||
@@ -62,6 +62,45 @@
|
|||||||
autocomplete="cc-name"/>
|
autocomplete="cc-name"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Card type selector (for terminal payments where card brand cannot be auto-detected) -->
|
||||||
|
<div class="mb-3 o_poynt_card_type_section" style="display:none;">
|
||||||
|
<label class="form-label">Card Type</label>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" name="poynt_card_type"
|
||||||
|
id="poynt_ct_visa" value="visa"/>
|
||||||
|
<label class="form-check-label" for="poynt_ct_visa">Visa</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" name="poynt_card_type"
|
||||||
|
id="poynt_ct_mc" value="mastercard"/>
|
||||||
|
<label class="form-check-label" for="poynt_ct_mc">Mastercard</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" name="poynt_card_type"
|
||||||
|
id="poynt_ct_amex" value="amex"/>
|
||||||
|
<label class="form-check-label" for="poynt_ct_amex">Amex</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="radio" class="form-check-input" name="poynt_card_type"
|
||||||
|
id="poynt_ct_other" value="other" checked="checked"/>
|
||||||
|
<label class="form-check-label" for="poynt_ct_other">Other</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Surcharge notice (shown when surcharge is enabled) -->
|
||||||
|
<div class="mb-3 o_poynt_surcharge_notice" style="display:none;">
|
||||||
|
<div class="alert alert-info py-2 mb-0">
|
||||||
|
<small>
|
||||||
|
<i class="fa fa-info-circle me-1"/>
|
||||||
|
<span>A credit card processing fee of </span>
|
||||||
|
<strong id="poynt_surcharge_rate">0.00</strong>
|
||||||
|
<span>% (<strong id="poynt_surcharge_amount">$0.00</strong>) will be added to your total.</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Terminal payment option -->
|
<!-- Terminal payment option -->
|
||||||
<div class="mb-3 o_poynt_terminal_section" style="display:none;">
|
<div class="mb-3 o_poynt_terminal_section" style="display:none;">
|
||||||
<hr/>
|
<hr/>
|
||||||
|
|||||||
146
fusion_poynt/views/res_config_settings_views.xml
Normal file
146
fusion_poynt/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="res_config_settings_view_form_fusion_poynt" model="ir.ui.view">
|
||||||
|
<field name="name">res.config.settings.view.form.fusion.poynt</field>
|
||||||
|
<field name="model">res.config.settings</field>
|
||||||
|
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//form" position="inside">
|
||||||
|
<app data-string="Fusion Poynt" string="Fusion Poynt" name="fusion_poynt"
|
||||||
|
groups="fusion_poynt.group_fusion_poynt_admin">
|
||||||
|
|
||||||
|
<h2>Credit Card Surcharge</h2>
|
||||||
|
<div class="row mt-4 o_settings_container">
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_left_pane">
|
||||||
|
<field name="poynt_surcharge_enabled"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">Credit Card Processing Fee</span>
|
||||||
|
<div class="text-muted">
|
||||||
|
Automatically add a surcharge line to invoices when collecting payment
|
||||||
|
via Poynt. The fee is calculated as a percentage of the invoice total.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4 o_settings_container"
|
||||||
|
invisible="not poynt_surcharge_enabled">
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">Surcharge Rates by Card Type</span>
|
||||||
|
<div class="text-muted mb-2">
|
||||||
|
Configure the processing fee percentage for each card brand.
|
||||||
|
The surcharge is added as a separate invoice line before payment.
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label for="poynt_surcharge_visa_rate"
|
||||||
|
class="col-5 col-form-label">Visa</label>
|
||||||
|
<div class="col-4">
|
||||||
|
<field name="poynt_surcharge_visa_rate" class="o_input"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 col-form-label">%</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label for="poynt_surcharge_mastercard_rate"
|
||||||
|
class="col-5 col-form-label">Mastercard</label>
|
||||||
|
<div class="col-4">
|
||||||
|
<field name="poynt_surcharge_mastercard_rate" class="o_input"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 col-form-label">%</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label for="poynt_surcharge_amex_rate"
|
||||||
|
class="col-5 col-form-label">American Express</label>
|
||||||
|
<div class="col-4">
|
||||||
|
<field name="poynt_surcharge_amex_rate" class="o_input"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 col-form-label">%</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label for="poynt_surcharge_debit_rate"
|
||||||
|
class="col-5 col-form-label">Debit</label>
|
||||||
|
<div class="col-4">
|
||||||
|
<field name="poynt_surcharge_debit_rate" class="o_input"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 col-form-label">%</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label for="poynt_surcharge_other_rate"
|
||||||
|
class="col-5 col-form-label">Other Cards</label>
|
||||||
|
<div class="col-4">
|
||||||
|
<field name="poynt_surcharge_other_rate" class="o_input"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 col-form-label">%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">Surcharge Product</span>
|
||||||
|
<div class="text-muted mb-2">
|
||||||
|
The service product used for the processing fee invoice line.
|
||||||
|
Must be a service product with no taxes applied.
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<field name="poynt_surcharge_product_id"
|
||||||
|
domain="[('type', '=', 'service')]"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Quick Links</h2>
|
||||||
|
<div class="row mt-4 o_settings_container">
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">Payment Provider</span>
|
||||||
|
<div class="text-muted mb-2">
|
||||||
|
Configure your Poynt API credentials, business ID,
|
||||||
|
and default terminal.
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button name="action_open_poynt_provider"
|
||||||
|
type="object"
|
||||||
|
string="Configure Payment Provider"
|
||||||
|
class="btn-link"
|
||||||
|
icon="fa-arrow-right"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<span class="o_form_label">Terminals</span>
|
||||||
|
<div class="text-muted mb-2">
|
||||||
|
View and manage your Poynt terminal devices.
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button name="action_open_poynt_terminals"
|
||||||
|
type="object"
|
||||||
|
string="Manage Terminals"
|
||||||
|
class="btn-link"
|
||||||
|
icon="fa-arrow-right"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</app>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fusion_poynt_settings" model="ir.actions.act_window">
|
||||||
|
<field name="name">Fusion Poynt Settings</field>
|
||||||
|
<field name="res_model">res.config.settings</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">current</field>
|
||||||
|
<field name="context" eval="{'module': 'fusion_poynt'}"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -58,6 +58,37 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
default='terminal',
|
default='terminal',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Card type & surcharge fields ---
|
||||||
|
card_type = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
('visa', "Visa"),
|
||||||
|
('mastercard', "Mastercard"),
|
||||||
|
('amex', "American Express"),
|
||||||
|
('debit', "Debit"),
|
||||||
|
('other', "Other"),
|
||||||
|
],
|
||||||
|
string="Card Type",
|
||||||
|
)
|
||||||
|
surcharge_enabled = fields.Boolean(
|
||||||
|
compute='_compute_surcharge_enabled',
|
||||||
|
)
|
||||||
|
surcharge_rate = fields.Float(
|
||||||
|
string="Surcharge Rate (%)",
|
||||||
|
digits=(5, 2),
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
surcharge_amount = fields.Monetary(
|
||||||
|
string="Surcharge Amount",
|
||||||
|
currency_field='currency_id',
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
surcharge_applied = fields.Boolean(default=False)
|
||||||
|
original_amount = fields.Monetary(
|
||||||
|
string="Invoice Amount",
|
||||||
|
currency_field='currency_id',
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
# --- Terminal fields ---
|
# --- Terminal fields ---
|
||||||
terminal_id = fields.Many2one(
|
terminal_id = fields.Many2one(
|
||||||
'poynt.terminal',
|
'poynt.terminal',
|
||||||
@@ -102,6 +133,58 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
readonly=True,
|
readonly=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api.depends_context('uid')
|
||||||
|
def _compute_surcharge_enabled(self):
|
||||||
|
enabled = self.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'fusion_poynt.surcharge_enabled', 'False',
|
||||||
|
) == 'True'
|
||||||
|
for rec in self:
|
||||||
|
rec.surcharge_enabled = enabled
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _detect_card_brand(card_number):
|
||||||
|
num = (card_number or '').replace(' ', '')
|
||||||
|
if len(num) < 2:
|
||||||
|
return 'other'
|
||||||
|
if num[:2] in ('34', '37'):
|
||||||
|
return 'amex'
|
||||||
|
if num[0] == '4':
|
||||||
|
return 'visa'
|
||||||
|
prefix2 = int(num[:2])
|
||||||
|
if 51 <= prefix2 <= 55:
|
||||||
|
return 'mastercard'
|
||||||
|
if len(num) >= 4:
|
||||||
|
prefix4 = int(num[:4])
|
||||||
|
if 2221 <= prefix4 <= 2720:
|
||||||
|
return 'mastercard'
|
||||||
|
return 'other'
|
||||||
|
|
||||||
|
def _get_surcharge_rate(self, card_type):
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
rate_key = {
|
||||||
|
'visa': 'fusion_poynt.surcharge_visa_rate',
|
||||||
|
'mastercard': 'fusion_poynt.surcharge_mastercard_rate',
|
||||||
|
'amex': 'fusion_poynt.surcharge_amex_rate',
|
||||||
|
'debit': 'fusion_poynt.surcharge_debit_rate',
|
||||||
|
}.get(card_type, 'fusion_poynt.surcharge_other_rate')
|
||||||
|
return float(ICP.get_param(rate_key, '0') or 0)
|
||||||
|
|
||||||
|
@api.onchange('card_number')
|
||||||
|
def _onchange_card_number(self):
|
||||||
|
if self.payment_mode == 'card' and self.card_number:
|
||||||
|
self.card_type = self._detect_card_brand(self.card_number)
|
||||||
|
|
||||||
|
@api.onchange('card_type')
|
||||||
|
def _onchange_card_type(self):
|
||||||
|
if not self.card_type or not self.surcharge_enabled:
|
||||||
|
self.surcharge_rate = 0.0
|
||||||
|
self.surcharge_amount = 0.0
|
||||||
|
return
|
||||||
|
rate = self._get_surcharge_rate(self.card_type)
|
||||||
|
base_amount = self.original_amount or self.amount
|
||||||
|
self.surcharge_rate = rate
|
||||||
|
self.surcharge_amount = round(base_amount * rate / 100.0, 2)
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def default_get(self, fields_list):
|
def default_get(self, fields_list):
|
||||||
res = super().default_get(fields_list)
|
res = super().default_get(fields_list)
|
||||||
@@ -112,6 +195,7 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
invoice = self.env['account.move'].browse(invoice_id)
|
invoice = self.env['account.move'].browse(invoice_id)
|
||||||
res['invoice_id'] = invoice.id
|
res['invoice_id'] = invoice.id
|
||||||
res['amount'] = invoice.amount_residual
|
res['amount'] = invoice.amount_residual
|
||||||
|
res['original_amount'] = invoice.amount_residual
|
||||||
res['currency_id'] = invoice.currency_id.id
|
res['currency_id'] = invoice.currency_id.id
|
||||||
|
|
||||||
provider = self.env['payment.provider'].sudo().search([
|
provider = self.env['payment.provider'].sudo().search([
|
||||||
@@ -135,10 +219,103 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
if provider.poynt_default_terminal_id:
|
if provider.poynt_default_terminal_id:
|
||||||
self.terminal_id = provider.poynt_default_terminal_id
|
self.terminal_id = provider.poynt_default_terminal_id
|
||||||
|
|
||||||
|
def _apply_surcharge_if_needed(self):
|
||||||
|
"""Add the surcharge invoice line if surcharge is enabled and not yet applied."""
|
||||||
|
if self.surcharge_applied or not self.surcharge_enabled:
|
||||||
|
return
|
||||||
|
if not self.card_type:
|
||||||
|
raise UserError(_("Please select the card type to calculate the surcharge."))
|
||||||
|
|
||||||
|
rate = self._get_surcharge_rate(self.card_type)
|
||||||
|
if rate <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
base_amount = self.original_amount or self.amount
|
||||||
|
fee_amount = round(base_amount * rate / 100.0, 2)
|
||||||
|
if fee_amount <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
product_id = int(ICP.get_param('fusion_poynt.surcharge_product_id', '0') or 0)
|
||||||
|
product = self.env['product.product'].sudo().browse(product_id).exists()
|
||||||
|
if not product:
|
||||||
|
product = self.env.ref('fusion_poynt.product_cc_processing_fee', raise_if_not_found=False)
|
||||||
|
if not product:
|
||||||
|
raise UserError(
|
||||||
|
_("Surcharge product not configured. "
|
||||||
|
"Go to Settings > Fusion Poynt to set it up.")
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice = self.invoice_id.sudo()
|
||||||
|
|
||||||
|
was_posted = invoice.state == 'posted'
|
||||||
|
if was_posted:
|
||||||
|
invoice.button_draft()
|
||||||
|
|
||||||
|
description = _("Credit Card Processing Fee (%(rate).2f%% surcharge)", rate=rate)
|
||||||
|
invoice.write({
|
||||||
|
'invoice_line_ids': [(0, 0, {
|
||||||
|
'product_id': product.id,
|
||||||
|
'name': description,
|
||||||
|
'quantity': 1,
|
||||||
|
'price_unit': fee_amount,
|
||||||
|
'tax_ids': [(5, 0, 0)],
|
||||||
|
})],
|
||||||
|
})
|
||||||
|
|
||||||
|
if was_posted:
|
||||||
|
invoice.action_post()
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'surcharge_applied': True,
|
||||||
|
'surcharge_rate': rate,
|
||||||
|
'surcharge_amount': fee_amount,
|
||||||
|
'amount': invoice.amount_residual,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _remove_surcharge_line(self):
|
||||||
|
"""Remove the surcharge line from the invoice if it was applied."""
|
||||||
|
if not self.surcharge_applied:
|
||||||
|
return
|
||||||
|
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
product_id = int(ICP.get_param('fusion_poynt.surcharge_product_id', '0') or 0)
|
||||||
|
product = self.env['product.product'].sudo().browse(product_id).exists()
|
||||||
|
if not product:
|
||||||
|
product = self.env.ref('fusion_poynt.product_cc_processing_fee', raise_if_not_found=False)
|
||||||
|
if not product:
|
||||||
|
return
|
||||||
|
|
||||||
|
invoice = self.invoice_id.sudo()
|
||||||
|
surcharge_lines = invoice.invoice_line_ids.filtered(
|
||||||
|
lambda l: l.product_id.id == product.id
|
||||||
|
)
|
||||||
|
if not surcharge_lines:
|
||||||
|
self.surcharge_applied = False
|
||||||
|
return
|
||||||
|
|
||||||
|
was_posted = invoice.state == 'posted'
|
||||||
|
if was_posted:
|
||||||
|
invoice.button_draft()
|
||||||
|
|
||||||
|
surcharge_lines.unlink()
|
||||||
|
|
||||||
|
if was_posted:
|
||||||
|
invoice.action_post()
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'surcharge_applied': False,
|
||||||
|
'surcharge_amount': 0.0,
|
||||||
|
'surcharge_rate': 0.0,
|
||||||
|
'amount': invoice.amount_residual,
|
||||||
|
})
|
||||||
|
|
||||||
def action_collect_payment(self):
|
def action_collect_payment(self):
|
||||||
"""Dispatch to the appropriate payment method."""
|
"""Dispatch to the appropriate payment method."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|
||||||
|
self._apply_surcharge_if_needed()
|
||||||
|
|
||||||
if self.amount <= 0:
|
if self.amount <= 0:
|
||||||
raise UserError(_("Payment amount must be greater than zero."))
|
raise UserError(_("Payment amount must be greater than zero."))
|
||||||
|
|
||||||
@@ -187,6 +364,7 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
|
|
||||||
except (ValidationError, UserError) as e:
|
except (ValidationError, UserError) as e:
|
||||||
self._cleanup_draft_transaction()
|
self._cleanup_draft_transaction()
|
||||||
|
self._remove_surcharge_line()
|
||||||
self.write({
|
self.write({
|
||||||
'state': 'error',
|
'state': 'error',
|
||||||
'status_message': str(e),
|
'status_message': str(e),
|
||||||
@@ -273,6 +451,31 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
'poynt_status': status,
|
'poynt_status': status,
|
||||||
'funding_source': result.get('fundingSource', {}),
|
'funding_source': result.get('fundingSource', {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if status in ('DECLINED', 'FAILED', 'REFUND_FAILED'):
|
||||||
|
tx._set_error(
|
||||||
|
_("Payment was %(status)s by the processor.",
|
||||||
|
status=status.lower())
|
||||||
|
)
|
||||||
|
self._cleanup_draft_transaction()
|
||||||
|
self._remove_surcharge_line()
|
||||||
|
processor = result.get('processorResponse', {})
|
||||||
|
decline_msg = (
|
||||||
|
processor.get('statusMessage')
|
||||||
|
or processor.get('message')
|
||||||
|
or status.lower()
|
||||||
|
)
|
||||||
|
self.write({
|
||||||
|
'state': 'error',
|
||||||
|
'status_message': _(
|
||||||
|
"Payment %(status)s: %(reason)s",
|
||||||
|
status=status.lower(),
|
||||||
|
reason=decline_msg,
|
||||||
|
),
|
||||||
|
'poynt_transaction_ref': transaction_id,
|
||||||
|
})
|
||||||
|
return self._reopen_wizard()
|
||||||
|
|
||||||
tx._process('poynt', payment_data)
|
tx._process('poynt', payment_data)
|
||||||
|
|
||||||
self.write({
|
self.write({
|
||||||
@@ -288,6 +491,7 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
|
|
||||||
except (ValidationError, UserError) as e:
|
except (ValidationError, UserError) as e:
|
||||||
self._cleanup_draft_transaction()
|
self._cleanup_draft_transaction()
|
||||||
|
self._remove_surcharge_line()
|
||||||
self.write({
|
self.write({
|
||||||
'state': 'error',
|
'state': 'error',
|
||||||
'status_message': str(e),
|
'status_message': str(e),
|
||||||
@@ -352,6 +556,7 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
|
|
||||||
if status in ('DECLINED', 'VOIDED', 'REFUNDED'):
|
if status in ('DECLINED', 'VOIDED', 'REFUNDED'):
|
||||||
self._cleanup_draft_transaction()
|
self._cleanup_draft_transaction()
|
||||||
|
self._remove_surcharge_line()
|
||||||
self.write({
|
self.write({
|
||||||
'state': 'error',
|
'state': 'error',
|
||||||
'status_message': _(
|
'status_message': _(
|
||||||
@@ -473,6 +678,7 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
"""Cancel the payment and clean up the draft transaction."""
|
"""Cancel the payment and clean up the draft transaction."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
self._cleanup_draft_transaction()
|
self._cleanup_draft_transaction()
|
||||||
|
self._remove_surcharge_line()
|
||||||
return {'type': 'ir.actions.act_window_close'}
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
def _cleanup_draft_transaction(self):
|
def _cleanup_draft_transaction(self):
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
<field name="state" invisible="1"/>
|
<field name="state" invisible="1"/>
|
||||||
<field name="poynt_transaction_ref" invisible="1"/>
|
<field name="poynt_transaction_ref" invisible="1"/>
|
||||||
<field name="provider_id" invisible="1"/>
|
<field name="provider_id" invisible="1"/>
|
||||||
|
<field name="surcharge_enabled" invisible="1"/>
|
||||||
|
<field name="surcharge_applied" invisible="1"/>
|
||||||
|
<field name="original_amount" invisible="1"/>
|
||||||
|
|
||||||
<!-- Status banner for waiting / done / error -->
|
<!-- Status banner for waiting / done / error -->
|
||||||
<div class="alert alert-info" role="alert"
|
<div class="alert alert-info" role="alert"
|
||||||
@@ -44,6 +47,25 @@
|
|||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
|
<!-- Card Type & Surcharge section -->
|
||||||
|
<group string="Card Type & Surcharge"
|
||||||
|
invisible="state == 'done' or not surcharge_enabled">
|
||||||
|
<group>
|
||||||
|
<field name="card_type"
|
||||||
|
widget="radio"
|
||||||
|
required="surcharge_enabled and state in ('draft', 'error')"
|
||||||
|
readonly="state not in ('draft', 'error')"/>
|
||||||
|
</group>
|
||||||
|
<group invisible="not card_type">
|
||||||
|
<field name="surcharge_rate" string="Rate (%)"/>
|
||||||
|
<field name="surcharge_amount"/>
|
||||||
|
<div class="text-muted" colspan="2"
|
||||||
|
invisible="surcharge_amount == 0">
|
||||||
|
A surcharge line will be added to the invoice before payment.
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
<!-- Terminal section -->
|
<!-- Terminal section -->
|
||||||
<group string="Terminal"
|
<group string="Terminal"
|
||||||
invisible="payment_mode != 'terminal' or state == 'done'">
|
invisible="payment_mode != 'terminal' or state == 'done'">
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ class SaleOrder(models.Model):
|
|||||||
copy=False,
|
copy=False,
|
||||||
help="Stored card used for automatic renewal payment collection.",
|
help="Stored card used for automatic renewal payment collection.",
|
||||||
)
|
)
|
||||||
|
rental_apply_cc_fee = fields.Boolean(
|
||||||
|
string="Apply CC Processing Fee",
|
||||||
|
default=True,
|
||||||
|
help="When enabled, a credit card processing fee is added to "
|
||||||
|
"invoices charged via stored card during auto-renewal.",
|
||||||
|
)
|
||||||
rental_original_duration = fields.Integer(
|
rental_original_duration = fields.Integer(
|
||||||
string="Original Duration (Days)",
|
string="Original Duration (Days)",
|
||||||
compute='_compute_rental_original_duration',
|
compute='_compute_rental_original_duration',
|
||||||
@@ -574,13 +580,17 @@ class SaleOrder(models.Model):
|
|||||||
def _get_rental_only_lines(self):
|
def _get_rental_only_lines(self):
|
||||||
"""Return order lines that should be invoiced on renewal.
|
"""Return order lines that should be invoiced on renewal.
|
||||||
|
|
||||||
Excludes security deposits, delivery/installation, and any other
|
Excludes security deposits, delivery/installation, fully returned
|
||||||
one-time charges. Only lines flagged as rental by Odoo core
|
items, and any other one-time charges. Only lines flagged as
|
||||||
(is_rental=True) are included.
|
rental by Odoo core (is_rental=True) are included.
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
return self.order_line.filtered(
|
return self.order_line.filtered(
|
||||||
lambda l: l.is_rental and not l.is_security_deposit
|
lambda l: (
|
||||||
|
l.is_rental
|
||||||
|
and not l.is_security_deposit
|
||||||
|
and l.qty_returned < l.product_uom_qty
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_renewal_amount(self):
|
def _get_renewal_amount(self):
|
||||||
|
|||||||
@@ -122,6 +122,8 @@
|
|||||||
</group>
|
</group>
|
||||||
<group string="Payment">
|
<group string="Payment">
|
||||||
<field name="rental_payment_token_id"/>
|
<field name="rental_payment_token_id"/>
|
||||||
|
<field name="rental_apply_cc_fee"
|
||||||
|
invisible="not rental_payment_token_id"/>
|
||||||
<field name="rental_charges_invoice_id"
|
<field name="rental_charges_invoice_id"
|
||||||
invisible="not rental_charges_invoice_id" readonly="1"/>
|
invisible="not rental_charges_invoice_id" readonly="1"/>
|
||||||
<field name="rental_deposit_invoice_id"
|
<field name="rental_deposit_invoice_id"
|
||||||
|
|||||||
Reference in New Issue
Block a user