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

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