This commit is contained in:
gsinghpal
2026-03-20 11:46:41 -04:00
parent 595dccc17d
commit 92369be6e0
71 changed files with 6588 additions and 8 deletions

17
fusion_clover/__init__.py Normal file
View 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})

View 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',
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

85
fusion_clover/const.py Normal file
View 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',
}

View File

@@ -0,0 +1,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
from . import portal

View 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.'}

View 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,
)

View 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 &lt; 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 &lt; 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>

View 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>

View 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>

View 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

View 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)

View 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"

View 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,
},
}

View 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',
)

View 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 {}

View 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')],
}

View 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,
},
}

View 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>

View 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 &lt; 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>

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_clover_payment_wizard_user clover.payment.wizard.user model_clover_payment_wizard group_fusion_clover_user 1 1 1 0
3 access_clover_payment_wizard_admin clover.payment.wizard.admin model_clover_payment_wizard group_fusion_clover_admin 1 1 1 1
4 access_clover_refund_wizard_user clover.refund.wizard.user model_clover_refund_wizard group_fusion_clover_user 1 1 1 0
5 access_clover_refund_wizard_admin clover.refund.wizard.admin model_clover_refund_wizard group_fusion_clover_admin 1 1 1 1
6 access_payment_provider_clover_user payment.provider.clover.user payment.model_payment_provider group_fusion_clover_user 1 0 0 0
7 access_payment_transaction_clover_user payment.transaction.clover.user payment.model_payment_transaction group_fusion_clover_user 1 1 1 0
8 access_payment_method_clover_user payment.method.clover.user payment.model_payment_method group_fusion_clover_user 1 0 0 0
9 access_clover_terminal_user clover.terminal.user model_clover_terminal group_fusion_clover_user 1 0 0 0
10 access_clover_terminal_admin clover.terminal.admin model_clover_terminal group_fusion_clover_admin 1 1 1 1

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View 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
View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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

View 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',
}

View 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 &amp; 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>

View 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',
}

View 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>

View File

@@ -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',

View File

@@ -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

View File

@@ -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,

View 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,
)

View 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>

View File

@@ -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

View File

@@ -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 === #

View File

@@ -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))

View 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',
}

View File

@@ -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) {

View File

@@ -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/>

View 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>

View File

@@ -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):

View File

@@ -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 &amp; 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'">

View File

@@ -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):

View File

@@ -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"