629 lines
21 KiB
Python
629 lines
21 KiB
Python
# 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',
|
|
}
|