changes
This commit is contained in:
4
fusion_clover/wizard/__init__.py
Normal file
4
fusion_clover/wizard/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import clover_payment_wizard
|
||||
from . import clover_refund_wizard
|
||||
BIN
fusion_clover/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clover/wizard/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
628
fusion_clover/wizard/clover_payment_wizard.py
Normal file
628
fusion_clover/wizard/clover_payment_wizard.py
Normal file
@@ -0,0 +1,628 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
from odoo.addons.fusion_clover import utils as clover_utils
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloverPaymentWizard(models.TransientModel):
|
||||
_name = 'clover.payment.wizard'
|
||||
_description = 'Collect Clover Payment'
|
||||
|
||||
invoice_id = fields.Many2one(
|
||||
'account.move',
|
||||
string="Invoice",
|
||||
required=True,
|
||||
readonly=True,
|
||||
domain="[('move_type', 'in', ('out_invoice', 'out_refund'))]",
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
related='invoice_id.partner_id',
|
||||
string="Customer",
|
||||
)
|
||||
amount = fields.Monetary(
|
||||
string="Amount",
|
||||
required=True,
|
||||
currency_field='currency_id',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
string="Currency",
|
||||
required=True,
|
||||
readonly=True,
|
||||
)
|
||||
provider_id = fields.Many2one(
|
||||
'payment.provider',
|
||||
string="Clover Provider",
|
||||
required=True,
|
||||
domain="[('code', '=', 'clover'), ('state', '!=', 'disabled')]",
|
||||
)
|
||||
provider_name = fields.Char(
|
||||
related='provider_id.name',
|
||||
string="Clover Provider",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# --- Payment mode (terminal vs manual card) ---
|
||||
payment_mode = fields.Selection(
|
||||
selection=[
|
||||
('terminal', "Terminal"),
|
||||
('card', "Manual Card Entry"),
|
||||
],
|
||||
string="Payment Mode",
|
||||
default='terminal',
|
||||
required=True,
|
||||
)
|
||||
|
||||
# --- Terminal fields ---
|
||||
terminal_id = fields.Many2one(
|
||||
'clover.terminal',
|
||||
string="Terminal",
|
||||
domain="[('provider_id', '=', provider_id), ('active', '=', True)]",
|
||||
)
|
||||
|
||||
# --- Card type & surcharge fields ---
|
||||
card_type = fields.Selection(
|
||||
selection=[
|
||||
('visa', "Visa"),
|
||||
('mastercard', "Mastercard"),
|
||||
('amex', "American Express"),
|
||||
('debit', "Debit"),
|
||||
('other', "Other"),
|
||||
],
|
||||
string="Card Type",
|
||||
)
|
||||
surcharge_enabled = fields.Boolean(
|
||||
compute='_compute_surcharge_enabled',
|
||||
)
|
||||
surcharge_rate = fields.Float(
|
||||
string="Surcharge Rate (%)",
|
||||
digits=(5, 2),
|
||||
readonly=True,
|
||||
)
|
||||
surcharge_amount = fields.Monetary(
|
||||
string="Surcharge Amount",
|
||||
currency_field='currency_id',
|
||||
readonly=True,
|
||||
)
|
||||
surcharge_applied = fields.Boolean(default=False)
|
||||
original_amount = fields.Monetary(
|
||||
string="Invoice Amount",
|
||||
currency_field='currency_id',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# --- Card entry fields (never stored, transient only) ---
|
||||
card_number = fields.Char(string="Card Number")
|
||||
exp_month = fields.Char(string="Exp. Month", size=2)
|
||||
exp_year = fields.Char(string="Exp. Year", size=4)
|
||||
cvv = fields.Char(string="CVV", size=4)
|
||||
cardholder_name = fields.Char(string="Cardholder Name")
|
||||
|
||||
# --- Status tracking ---
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
('draft', "Draft"),
|
||||
('waiting', "Waiting for Terminal"),
|
||||
('done', "Payment Collected"),
|
||||
('error', "Error"),
|
||||
],
|
||||
default='draft',
|
||||
)
|
||||
status_message = fields.Text(string="Status", readonly=True)
|
||||
clover_charge_id = fields.Char(readonly=True)
|
||||
clover_payment_id = fields.Char(
|
||||
string="Terminal Payment ID",
|
||||
readonly=True,
|
||||
help="The Clover payment UUID from the terminal response.",
|
||||
)
|
||||
sent_at = fields.Datetime(
|
||||
string="Sent to Terminal At",
|
||||
readonly=True,
|
||||
)
|
||||
transaction_id = fields.Many2one(
|
||||
'payment.transaction',
|
||||
string="Payment Transaction",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
@api.depends_context('uid')
|
||||
def _compute_surcharge_enabled(self):
|
||||
enabled = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_clover.surcharge_enabled', 'False',
|
||||
) == 'True'
|
||||
for rec in self:
|
||||
rec.surcharge_enabled = enabled
|
||||
|
||||
@staticmethod
|
||||
def _detect_card_brand(card_number):
|
||||
num = (card_number or '').replace(' ', '')
|
||||
if len(num) < 2:
|
||||
return 'other'
|
||||
if num[:2] in ('34', '37'):
|
||||
return 'amex'
|
||||
if num[0] == '4':
|
||||
return 'visa'
|
||||
prefix2 = int(num[:2])
|
||||
if 51 <= prefix2 <= 55:
|
||||
return 'mastercard'
|
||||
if len(num) >= 4:
|
||||
prefix4 = int(num[:4])
|
||||
if 2221 <= prefix4 <= 2720:
|
||||
return 'mastercard'
|
||||
return 'other'
|
||||
|
||||
def _get_surcharge_rate(self, card_type):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
rate_key = {
|
||||
'visa': 'fusion_clover.surcharge_visa_rate',
|
||||
'mastercard': 'fusion_clover.surcharge_mastercard_rate',
|
||||
'amex': 'fusion_clover.surcharge_amex_rate',
|
||||
'debit': 'fusion_clover.surcharge_debit_rate',
|
||||
}.get(card_type, 'fusion_clover.surcharge_other_rate')
|
||||
return float(ICP.get_param(rate_key, '0') or 0)
|
||||
|
||||
@api.onchange('card_number')
|
||||
def _onchange_card_number(self):
|
||||
if self.card_number:
|
||||
self.card_type = self._detect_card_brand(self.card_number)
|
||||
|
||||
@api.onchange('card_type')
|
||||
def _onchange_card_type(self):
|
||||
if not self.card_type or not self.surcharge_enabled:
|
||||
self.surcharge_rate = 0.0
|
||||
self.surcharge_amount = 0.0
|
||||
return
|
||||
rate = self._get_surcharge_rate(self.card_type)
|
||||
base_amount = self.original_amount or self.amount
|
||||
self.surcharge_rate = rate
|
||||
self.surcharge_amount = round(base_amount * rate / 100.0, 2)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
invoice_id = self.env.context.get('active_id')
|
||||
active_model = self.env.context.get('active_model')
|
||||
|
||||
if active_model == 'account.move' and invoice_id:
|
||||
invoice = self.env['account.move'].browse(invoice_id)
|
||||
res['invoice_id'] = invoice.id
|
||||
res['amount'] = invoice.amount_residual
|
||||
res['original_amount'] = invoice.amount_residual
|
||||
res['currency_id'] = invoice.currency_id.id
|
||||
|
||||
provider = self.env['payment.provider'].sudo().search([
|
||||
('code', '=', 'clover'),
|
||||
('state', '!=', 'disabled'),
|
||||
], limit=1)
|
||||
if provider:
|
||||
res['provider_id'] = provider.id
|
||||
if provider.clover_default_terminal_id:
|
||||
res['terminal_id'] = provider.clover_default_terminal_id.id
|
||||
|
||||
return res
|
||||
|
||||
def _get_provider_sudo(self):
|
||||
return self.provider_id.sudo()
|
||||
|
||||
def _apply_surcharge_if_needed(self):
|
||||
"""Add the surcharge invoice line if surcharge is enabled and not yet applied."""
|
||||
if self.surcharge_applied or not self.surcharge_enabled:
|
||||
return
|
||||
if not self.card_type:
|
||||
raise UserError(_("Please select the card type to calculate the surcharge."))
|
||||
|
||||
rate = self._get_surcharge_rate(self.card_type)
|
||||
if rate <= 0:
|
||||
return
|
||||
|
||||
base_amount = self.original_amount or self.amount
|
||||
fee_amount = round(base_amount * rate / 100.0, 2)
|
||||
if fee_amount <= 0:
|
||||
return
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
product_id = int(ICP.get_param('fusion_clover.surcharge_product_id', '0') or 0)
|
||||
product = self.env['product.product'].sudo().browse(product_id).exists()
|
||||
if not product:
|
||||
product = self.env.ref('fusion_clover.product_cc_processing_fee', raise_if_not_found=False)
|
||||
if not product:
|
||||
raise UserError(
|
||||
_("Surcharge product not configured. "
|
||||
"Go to Settings > Fusion Clover to set it up.")
|
||||
)
|
||||
|
||||
invoice = self.invoice_id.sudo()
|
||||
was_posted = invoice.state == 'posted'
|
||||
if was_posted:
|
||||
invoice.button_draft()
|
||||
|
||||
description = _("Credit Card Processing Fee (%(rate).2f%% surcharge)", rate=rate)
|
||||
invoice.write({
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
'name': description,
|
||||
'quantity': 1,
|
||||
'price_unit': fee_amount,
|
||||
'tax_ids': [(5, 0, 0)],
|
||||
})],
|
||||
})
|
||||
|
||||
if was_posted:
|
||||
invoice.action_post()
|
||||
|
||||
self.write({
|
||||
'surcharge_applied': True,
|
||||
'surcharge_rate': rate,
|
||||
'surcharge_amount': fee_amount,
|
||||
'amount': invoice.amount_residual,
|
||||
})
|
||||
|
||||
def _remove_surcharge_line(self):
|
||||
"""Remove the surcharge line from the invoice if it was applied."""
|
||||
if not self.surcharge_applied:
|
||||
return
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
product_id = int(ICP.get_param('fusion_clover.surcharge_product_id', '0') or 0)
|
||||
product = self.env['product.product'].sudo().browse(product_id).exists()
|
||||
if not product:
|
||||
product = self.env.ref('fusion_clover.product_cc_processing_fee', raise_if_not_found=False)
|
||||
if not product:
|
||||
return
|
||||
|
||||
invoice = self.invoice_id.sudo()
|
||||
surcharge_lines = invoice.invoice_line_ids.filtered(
|
||||
lambda l: l.product_id.id == product.id
|
||||
)
|
||||
if not surcharge_lines:
|
||||
self.surcharge_applied = False
|
||||
return
|
||||
|
||||
was_posted = invoice.state == 'posted'
|
||||
if was_posted:
|
||||
invoice.button_draft()
|
||||
|
||||
surcharge_lines.unlink()
|
||||
|
||||
if was_posted:
|
||||
invoice.action_post()
|
||||
|
||||
self.write({
|
||||
'surcharge_applied': False,
|
||||
'surcharge_amount': 0.0,
|
||||
'surcharge_rate': 0.0,
|
||||
'amount': invoice.amount_residual,
|
||||
})
|
||||
|
||||
def action_collect_payment(self):
|
||||
"""Process a payment - either via terminal or manual card entry."""
|
||||
self.ensure_one()
|
||||
if self.payment_mode == 'terminal':
|
||||
return self._collect_via_terminal()
|
||||
return self._collect_via_card()
|
||||
|
||||
def _collect_via_terminal(self):
|
||||
"""Send payment to Clover terminal via Cloud REST Pay Display API."""
|
||||
self.ensure_one()
|
||||
self._apply_surcharge_if_needed()
|
||||
|
||||
if self.amount <= 0:
|
||||
raise UserError(_("Payment amount must be greater than zero."))
|
||||
if not self.terminal_id:
|
||||
raise UserError(_("Please select a terminal device."))
|
||||
|
||||
self._cleanup_draft_transaction()
|
||||
tx = self._create_payment_transaction()
|
||||
reference = tx.reference
|
||||
|
||||
try:
|
||||
provider = self._get_provider_sudo()
|
||||
capture = not provider.capture_manually
|
||||
|
||||
result = self.terminal_id.action_send_payment(
|
||||
amount=self.amount,
|
||||
currency=self.currency_id,
|
||||
reference=reference,
|
||||
capture=capture,
|
||||
)
|
||||
|
||||
# The terminal response may contain the payment immediately
|
||||
# (if the customer already tapped/swiped), or it may be pending.
|
||||
payment = result.get('payment', {})
|
||||
payment_id = payment.get('id', '')
|
||||
|
||||
if payment and payment.get('result') == 'SUCCESS':
|
||||
# Payment completed immediately
|
||||
card_txn = payment.get('cardTransaction', {})
|
||||
tx.write({
|
||||
'clover_charge_id': payment_id,
|
||||
'provider_reference': payment_id,
|
||||
})
|
||||
payment_data = {
|
||||
'reference': reference,
|
||||
'clover_charge_id': payment_id,
|
||||
'clover_status': 'succeeded',
|
||||
'source': {
|
||||
'brand': card_txn.get('cardType', ''),
|
||||
'last4': card_txn.get('last4', ''),
|
||||
},
|
||||
}
|
||||
tx._process('clover', payment_data)
|
||||
|
||||
self.write({
|
||||
'state': 'done',
|
||||
'status_message': _(
|
||||
"Payment collected successfully. Payment ID: %(pid)s",
|
||||
pid=payment_id,
|
||||
),
|
||||
'clover_payment_id': payment_id,
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
# Payment sent to terminal, waiting for customer interaction
|
||||
self.write({
|
||||
'state': 'waiting',
|
||||
'status_message': _("Payment sent to terminal. Waiting for customer..."),
|
||||
'sent_at': fields.Datetime.now(),
|
||||
'clover_payment_id': payment_id or '',
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
except (ValidationError, UserError) as e:
|
||||
self._cleanup_draft_transaction()
|
||||
self._remove_surcharge_line()
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'status_message': str(e),
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
def action_check_status(self):
|
||||
"""Poll the terminal for payment completion status."""
|
||||
self.ensure_one()
|
||||
if not self.terminal_id or not self.transaction_id:
|
||||
raise UserError(_("No terminal or transaction to check."))
|
||||
|
||||
tx = self.transaction_id
|
||||
reference = tx.reference
|
||||
|
||||
result = self.terminal_id.action_check_payment_status(reference)
|
||||
status = result.get('status', 'pending')
|
||||
|
||||
if status in ('CLOSED', 'AUTH', 'AUTHORIZED', 'CAPTURED'):
|
||||
payment_id = result.get('payment_id', '')
|
||||
card_txn = result.get('card_transaction', {})
|
||||
|
||||
tx.write({
|
||||
'clover_charge_id': payment_id or tx.clover_charge_id,
|
||||
'provider_reference': payment_id or tx.provider_reference,
|
||||
})
|
||||
payment_data = {
|
||||
'reference': reference,
|
||||
'clover_charge_id': payment_id,
|
||||
'clover_status': 'succeeded',
|
||||
'source': {
|
||||
'brand': card_txn.get('cardType', ''),
|
||||
'last4': card_txn.get('last4', ''),
|
||||
},
|
||||
}
|
||||
tx._process('clover', payment_data)
|
||||
|
||||
self.write({
|
||||
'state': 'done',
|
||||
'status_message': _(
|
||||
"Payment collected successfully. Payment ID: %(pid)s",
|
||||
pid=payment_id,
|
||||
),
|
||||
'clover_payment_id': payment_id,
|
||||
})
|
||||
|
||||
elif status in ('DECLINED', 'FAIL', 'FAILED'):
|
||||
tx._set_error(
|
||||
_("Payment was declined by the terminal.")
|
||||
)
|
||||
self._cleanup_draft_transaction()
|
||||
self._remove_surcharge_line()
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'status_message': result.get('message', _("Payment declined at terminal.")),
|
||||
})
|
||||
|
||||
elif status == 'error':
|
||||
self.write({
|
||||
'status_message': result.get('message', _("Error checking status.")),
|
||||
})
|
||||
|
||||
else:
|
||||
self.write({
|
||||
'status_message': result.get('message', _("Still waiting for terminal...")),
|
||||
})
|
||||
|
||||
return self._reopen_wizard()
|
||||
|
||||
def _collect_via_card(self):
|
||||
"""Process a manual card entry payment via Clover Ecommerce API."""
|
||||
self.ensure_one()
|
||||
self._apply_surcharge_if_needed()
|
||||
|
||||
if self.amount <= 0:
|
||||
raise UserError(_("Payment amount must be greater than zero."))
|
||||
|
||||
self._validate_card_fields()
|
||||
self._cleanup_draft_transaction()
|
||||
|
||||
tx = self._create_payment_transaction()
|
||||
reference = tx.reference
|
||||
|
||||
try:
|
||||
provider = self._get_provider_sudo()
|
||||
capture = not provider.capture_manually
|
||||
|
||||
minor_amount = clover_utils.format_clover_amount(
|
||||
self.amount, self.currency_id,
|
||||
)
|
||||
|
||||
payload = {
|
||||
'amount': minor_amount,
|
||||
'currency': self.currency_id.name.lower(),
|
||||
'capture': capture,
|
||||
'ecomind': 'moto',
|
||||
'description': reference,
|
||||
'source': self.card_number.replace(' ', ''),
|
||||
'metadata': {
|
||||
'odoo_reference': reference,
|
||||
},
|
||||
}
|
||||
|
||||
result = provider._clover_make_ecom_request(
|
||||
'POST', 'v1/charges', payload=payload,
|
||||
)
|
||||
|
||||
charge_id = result.get('id', '')
|
||||
status = result.get('status', '')
|
||||
|
||||
tx.write({
|
||||
'clover_charge_id': charge_id,
|
||||
'provider_reference': charge_id,
|
||||
})
|
||||
|
||||
payment_data = {
|
||||
'reference': reference,
|
||||
'clover_charge_id': charge_id,
|
||||
'clover_status': status,
|
||||
'source': result.get('source', {}),
|
||||
}
|
||||
|
||||
if status == 'failed':
|
||||
tx._set_error(
|
||||
_("Payment was %(status)s by the processor.", status=status)
|
||||
)
|
||||
self._cleanup_draft_transaction()
|
||||
self._remove_surcharge_line()
|
||||
outcome = result.get('outcome', {})
|
||||
decline_msg = outcome.get('type', status)
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'status_message': _(
|
||||
"Payment %(status)s: %(reason)s",
|
||||
status=status,
|
||||
reason=decline_msg,
|
||||
),
|
||||
'clover_charge_id': charge_id,
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
tx._process('clover', payment_data)
|
||||
|
||||
self.write({
|
||||
'state': 'done',
|
||||
'status_message': _(
|
||||
"Payment collected successfully. Charge: %(charge_id)s",
|
||||
charge_id=charge_id,
|
||||
),
|
||||
'clover_charge_id': charge_id,
|
||||
})
|
||||
|
||||
return self._reopen_wizard()
|
||||
|
||||
except (ValidationError, UserError) as e:
|
||||
self._cleanup_draft_transaction()
|
||||
self._remove_surcharge_line()
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'status_message': str(e),
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
def action_send_receipt(self):
|
||||
"""Email the payment receipt to the customer and close the wizard."""
|
||||
self.ensure_one()
|
||||
tx = self.transaction_id
|
||||
if not tx:
|
||||
raise UserError(_("No payment transaction found."))
|
||||
|
||||
template = self.env.ref(
|
||||
'fusion_clover.mail_template_clover_receipt', raise_if_not_found=False,
|
||||
)
|
||||
if not template:
|
||||
raise UserError(_("Receipt email template not found."))
|
||||
|
||||
template.send_mail(tx.id, force_send=True)
|
||||
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
def action_cancel_payment(self):
|
||||
"""Cancel the payment and clean up the draft transaction."""
|
||||
self.ensure_one()
|
||||
self._cleanup_draft_transaction()
|
||||
self._remove_surcharge_line()
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
def _cleanup_draft_transaction(self):
|
||||
"""Remove the draft payment transaction created by this wizard."""
|
||||
if not self.transaction_id:
|
||||
return
|
||||
tx = self.transaction_id.sudo()
|
||||
if tx.state == 'draft':
|
||||
tx.invoice_ids = [(5,)]
|
||||
tx.unlink()
|
||||
self.transaction_id = False
|
||||
|
||||
# === HELPERS === #
|
||||
|
||||
def _validate_card_fields(self):
|
||||
"""Validate that card entry fields are properly filled."""
|
||||
if not self.card_number or len(self.card_number.replace(' ', '')) < 13:
|
||||
raise UserError(_("Please enter a valid card number."))
|
||||
if not self.exp_month or not self.exp_month.isdigit():
|
||||
raise UserError(_("Please enter a valid expiry month (01-12)."))
|
||||
if not self.exp_year or not self.exp_year.isdigit() or len(self.exp_year) < 2:
|
||||
raise UserError(_("Please enter a valid expiry year."))
|
||||
if not self.cvv or not self.cvv.isdigit():
|
||||
raise UserError(_("Please enter the CVV."))
|
||||
|
||||
def _create_payment_transaction(self):
|
||||
"""Create a payment.transaction linked to the invoice."""
|
||||
PaymentMethod = self.env['payment.method'].sudo().with_context(active_test=False)
|
||||
payment_method = PaymentMethod.search(
|
||||
[('code', '=', 'card')], limit=1,
|
||||
)
|
||||
if not payment_method:
|
||||
payment_method = PaymentMethod.search(
|
||||
[('code', 'in', ('visa', 'mastercard'))], limit=1,
|
||||
)
|
||||
if not payment_method:
|
||||
raise UserError(
|
||||
_("No card payment method found. Please configure one "
|
||||
"in Settings > Payment Methods.")
|
||||
)
|
||||
|
||||
tx_values = {
|
||||
'provider_id': self.provider_id.id,
|
||||
'payment_method_id': payment_method.id,
|
||||
'amount': self.amount,
|
||||
'currency_id': self.currency_id.id,
|
||||
'partner_id': self.partner_id.id,
|
||||
'operation': 'offline',
|
||||
'invoice_ids': [(4, self.invoice_id.id)],
|
||||
}
|
||||
tx = self.env['payment.transaction'].sudo().create(tx_values)
|
||||
self.transaction_id = tx
|
||||
return tx
|
||||
|
||||
def _reopen_wizard(self):
|
||||
"""Return an action that re-opens this wizard record (keeps state)."""
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _("Collect Clover Payment"),
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'views': [(False, 'form')],
|
||||
'target': 'new',
|
||||
}
|
||||
157
fusion_clover/wizard/clover_payment_wizard_views.xml
Normal file
157
fusion_clover/wizard/clover_payment_wizard_views.xml
Normal file
@@ -0,0 +1,157 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="clover_payment_wizard_form" model="ir.ui.view">
|
||||
<field name="name">clover.payment.wizard.form</field>
|
||||
<field name="model">clover.payment.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Collect Clover Payment">
|
||||
<field name="state" invisible="1"/>
|
||||
<field name="clover_payment_id" invisible="1"/>
|
||||
<field name="provider_id" invisible="1"/>
|
||||
<field name="surcharge_enabled" invisible="1"/>
|
||||
<field name="surcharge_applied" invisible="1"/>
|
||||
<field name="original_amount" invisible="1"/>
|
||||
|
||||
<!-- Status banner for waiting / done / error -->
|
||||
<div class="alert alert-info" role="alert"
|
||||
invisible="state != 'waiting'">
|
||||
<strong>Waiting for terminal...</strong>
|
||||
<field name="status_message" nolabel="1"/>
|
||||
</div>
|
||||
<div class="alert alert-success" role="alert"
|
||||
invisible="state != 'done'">
|
||||
<strong>Payment Collected</strong>
|
||||
<br/>
|
||||
<field name="status_message" nolabel="1"/>
|
||||
</div>
|
||||
<div class="alert alert-danger" role="alert"
|
||||
invisible="state != 'error'">
|
||||
<strong>Error</strong>
|
||||
<br/>
|
||||
<field name="status_message" nolabel="1"/>
|
||||
</div>
|
||||
|
||||
<group invisible="state == 'done'">
|
||||
<group string="Payment Details">
|
||||
<field name="invoice_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="amount"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="provider_name"/>
|
||||
</group>
|
||||
<group string="Payment Mode"
|
||||
invisible="state not in ('draft', 'error')">
|
||||
<field name="payment_mode" widget="radio"
|
||||
readonly="state not in ('draft', 'error')"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Card Type & Surcharge section -->
|
||||
<group string="Card Type & Surcharge"
|
||||
invisible="state == 'done' or not surcharge_enabled">
|
||||
<group>
|
||||
<field name="card_type"
|
||||
widget="radio"
|
||||
required="surcharge_enabled and state in ('draft', 'error')"
|
||||
readonly="state not in ('draft', 'error')"/>
|
||||
</group>
|
||||
<group invisible="not card_type">
|
||||
<field name="surcharge_rate" string="Rate (%)"/>
|
||||
<field name="surcharge_amount"/>
|
||||
<div class="text-muted" colspan="2"
|
||||
invisible="surcharge_amount == 0">
|
||||
A surcharge line will be added to the invoice before payment.
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Terminal section -->
|
||||
<group string="Terminal"
|
||||
invisible="payment_mode != 'terminal' or state == 'done'">
|
||||
<field name="terminal_id"
|
||||
required="payment_mode == 'terminal' and state in ('draft', 'error')"
|
||||
readonly="state == 'waiting'"/>
|
||||
</group>
|
||||
|
||||
<!-- Card entry section -->
|
||||
<group string="Card Details"
|
||||
invisible="payment_mode != 'card' or state == 'done'">
|
||||
<group>
|
||||
<field name="card_number"
|
||||
placeholder="4111 1111 1111 1111"
|
||||
required="payment_mode == 'card' and state in ('draft', 'error')"
|
||||
password="True"/>
|
||||
<field name="cardholder_name"
|
||||
placeholder="Name on card"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="exp_month"
|
||||
placeholder="MM"
|
||||
required="payment_mode == 'card' and state in ('draft', 'error')"/>
|
||||
<field name="exp_year"
|
||||
placeholder="YYYY"
|
||||
required="payment_mode == 'card' and state in ('draft', 'error')"/>
|
||||
<field name="cvv"
|
||||
placeholder="123"
|
||||
required="payment_mode == 'card' and state in ('draft', 'error')"
|
||||
password="True"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<footer>
|
||||
<!-- Draft / Error state: show action buttons -->
|
||||
<button string="Send to Terminal"
|
||||
name="action_collect_payment"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="payment_mode != 'terminal' or state not in ('draft', 'error')"
|
||||
data-hotkey="q"/>
|
||||
<button string="Collect Payment"
|
||||
name="action_collect_payment"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="payment_mode != 'card' or state not in ('draft', 'error')"
|
||||
data-hotkey="q"/>
|
||||
|
||||
<!-- Waiting state: check status + cancel -->
|
||||
<button string="Check Status"
|
||||
name="action_check_status"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="state != 'waiting'"
|
||||
data-hotkey="q"/>
|
||||
<button string="Cancel Payment"
|
||||
name="action_cancel_payment"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
invisible="state not in ('waiting', 'error')"
|
||||
data-hotkey="x"/>
|
||||
|
||||
<!-- Done state: send receipt + close -->
|
||||
<button string="Send Receipt"
|
||||
name="action_send_receipt"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
icon="fa-envelope"
|
||||
invisible="state != 'done'"
|
||||
data-hotkey="s"/>
|
||||
<button string="Close"
|
||||
class="btn-secondary"
|
||||
special="cancel"
|
||||
invisible="state != 'done'"
|
||||
data-hotkey="x"/>
|
||||
|
||||
<!-- Draft state: cancel cleans up -->
|
||||
<button string="Cancel"
|
||||
name="action_cancel_payment"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
invisible="state != 'draft'"
|
||||
data-hotkey="x"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
476
fusion_clover/wizard/clover_refund_wizard.py
Normal file
476
fusion_clover/wizard/clover_refund_wizard.py
Normal file
@@ -0,0 +1,476 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
from odoo.addons.fusion_clover import const
|
||||
from odoo.addons.fusion_clover import utils as clover_utils
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloverRefundWizard(models.TransientModel):
|
||||
_name = 'clover.refund.wizard'
|
||||
_description = 'Refund via Clover'
|
||||
|
||||
credit_note_id = fields.Many2one(
|
||||
'account.move',
|
||||
string="Credit Note",
|
||||
required=True,
|
||||
readonly=True,
|
||||
)
|
||||
original_invoice_id = fields.Many2one(
|
||||
'account.move',
|
||||
string="Original Invoice",
|
||||
readonly=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
related='credit_note_id.partner_id',
|
||||
string="Customer",
|
||||
)
|
||||
amount = fields.Monetary(
|
||||
string="Refund Amount",
|
||||
required=True,
|
||||
currency_field='currency_id',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
string="Currency",
|
||||
required=True,
|
||||
readonly=True,
|
||||
)
|
||||
provider_id = fields.Many2one(
|
||||
'payment.provider',
|
||||
string="Clover Provider",
|
||||
required=True,
|
||||
readonly=True,
|
||||
)
|
||||
provider_name = fields.Char(
|
||||
related='provider_id.name',
|
||||
string="Clover Provider",
|
||||
readonly=True,
|
||||
)
|
||||
original_transaction_id = fields.Many2one(
|
||||
'payment.transaction',
|
||||
string="Original Transaction",
|
||||
readonly=True,
|
||||
)
|
||||
original_clover_charge_id = fields.Char(
|
||||
string="Clover Charge ID",
|
||||
readonly=True,
|
||||
)
|
||||
card_info = fields.Char(
|
||||
string="Card Used",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# --- Transaction age & refund method ---
|
||||
transaction_age_days = fields.Integer(
|
||||
string="Transaction Age (days)",
|
||||
readonly=True,
|
||||
)
|
||||
refund_type = fields.Selection(
|
||||
selection=[
|
||||
('referenced', "Referenced Refund"),
|
||||
('non_referenced', "Non-Referenced Credit"),
|
||||
],
|
||||
string="Refund Method",
|
||||
readonly=True,
|
||||
)
|
||||
refund_type_note = fields.Text(
|
||||
string="Note",
|
||||
readonly=True,
|
||||
)
|
||||
terminal_id = fields.Many2one(
|
||||
'clover.terminal',
|
||||
string="Terminal",
|
||||
domain="[('provider_id', '=', provider_id), ('active', '=', True)]",
|
||||
help="Optional: select a terminal if the customer's card is present. "
|
||||
"Leave empty to issue a non-referenced credit via the Ecommerce API.",
|
||||
)
|
||||
|
||||
refund_transaction_id = fields.Many2one(
|
||||
'payment.transaction',
|
||||
string="Refund Transaction",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
('confirm', "Confirm"),
|
||||
('done', "Refunded"),
|
||||
('error', "Error"),
|
||||
],
|
||||
default='confirm',
|
||||
)
|
||||
status_message = fields.Text(string="Status", readonly=True)
|
||||
|
||||
def _get_provider_sudo(self):
|
||||
return self.provider_id.sudo()
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
credit_note_id = self.env.context.get('active_id')
|
||||
active_model = self.env.context.get('active_model')
|
||||
|
||||
if active_model != 'account.move' or not credit_note_id:
|
||||
return res
|
||||
|
||||
credit_note = self.env['account.move'].browse(credit_note_id)
|
||||
res['credit_note_id'] = credit_note.id
|
||||
res['amount'] = abs(credit_note.amount_residual) or abs(credit_note.amount_total)
|
||||
res['currency_id'] = credit_note.currency_id.id
|
||||
|
||||
orig_tx = credit_note._get_original_clover_transaction()
|
||||
if not orig_tx:
|
||||
raise UserError(_(
|
||||
"No Clover payment transaction found for the original invoice. "
|
||||
"This credit note cannot be refunded via Clover."
|
||||
))
|
||||
|
||||
res['original_transaction_id'] = orig_tx.id
|
||||
res['provider_id'] = orig_tx.provider_id.id
|
||||
res['original_invoice_id'] = credit_note.reversed_entry_id.id
|
||||
res['original_clover_charge_id'] = orig_tx.clover_charge_id
|
||||
|
||||
# Pre-select default terminal
|
||||
provider = orig_tx.provider_id.sudo()
|
||||
if provider.clover_default_terminal_id:
|
||||
res['terminal_id'] = provider.clover_default_terminal_id.id
|
||||
|
||||
# Transaction age & refund method
|
||||
age_days = 0
|
||||
if orig_tx.create_date:
|
||||
age_days = (fields.Datetime.now() - orig_tx.create_date).days
|
||||
res['transaction_age_days'] = age_days
|
||||
|
||||
if age_days > const.REFERENCED_REFUND_LIMIT_DAYS:
|
||||
res['refund_type'] = 'non_referenced'
|
||||
res['refund_type_note'] = _(
|
||||
"This transaction is %(days)s days old (limit is %(limit)s "
|
||||
"days). A non-referenced credit will be issued. This "
|
||||
"requires the customer's card to be present on the terminal.",
|
||||
days=age_days,
|
||||
limit=const.REFERENCED_REFUND_LIMIT_DAYS,
|
||||
)
|
||||
else:
|
||||
res['refund_type'] = 'referenced'
|
||||
res['refund_type_note'] = _(
|
||||
"This transaction is %(days)s days old (within the %(limit)s-day "
|
||||
"limit). A referenced refund will be issued back to the "
|
||||
"original card automatically.",
|
||||
days=age_days,
|
||||
limit=const.REFERENCED_REFUND_LIMIT_DAYS,
|
||||
)
|
||||
|
||||
# Card info from receipt data
|
||||
receipt_data = orig_tx.clover_receipt_data
|
||||
if receipt_data:
|
||||
try:
|
||||
data = json.loads(receipt_data)
|
||||
card_brand = data.get('card_brand', '')
|
||||
card_last4 = data.get('card_last4', '')
|
||||
if card_brand or card_last4:
|
||||
res['card_info'] = f"{card_brand} ****{card_last4}"
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
return res
|
||||
|
||||
def action_process_refund(self):
|
||||
"""Dispatch to referenced refund or non-referenced credit."""
|
||||
self.ensure_one()
|
||||
|
||||
if self.amount <= 0:
|
||||
raise UserError(_("Refund amount must be greater than zero."))
|
||||
|
||||
orig_tx = self.original_transaction_id
|
||||
if orig_tx.clover_voided:
|
||||
raise UserError(_(
|
||||
"This transaction was already voided on %(date)s. "
|
||||
"A voided transaction cannot also be refunded — the charge "
|
||||
"was already reversed before settlement.",
|
||||
date=orig_tx.clover_void_date,
|
||||
))
|
||||
|
||||
# Pre-refund verification on Clover
|
||||
self._verify_not_already_reversed()
|
||||
|
||||
if self.refund_type == 'non_referenced':
|
||||
return self._process_non_referenced_credit()
|
||||
return self._process_referenced_refund()
|
||||
|
||||
def _verify_not_already_reversed(self):
|
||||
"""Check on Clover that the charge hasn't been fully refunded already."""
|
||||
orig_tx = self.original_transaction_id
|
||||
charge_id = orig_tx.clover_charge_id or orig_tx.provider_reference
|
||||
if not charge_id:
|
||||
return
|
||||
|
||||
provider = self._get_provider_sudo()
|
||||
try:
|
||||
provider._clover_verify_charge_not_reversed(charge_id)
|
||||
except (UserError, ValidationError):
|
||||
raise
|
||||
except Exception:
|
||||
_logger.debug("Could not verify charge %s before refund", charge_id)
|
||||
|
||||
def _process_referenced_refund(self):
|
||||
"""Send a referenced refund via Clover Ecommerce API."""
|
||||
orig_tx = self.original_transaction_id
|
||||
charge_id = orig_tx.clover_charge_id or orig_tx.provider_reference
|
||||
provider = self._get_provider_sudo()
|
||||
|
||||
try:
|
||||
result = provider._clover_create_refund(
|
||||
charge_id=charge_id,
|
||||
amount=self.amount,
|
||||
currency=self.currency_id,
|
||||
reason=f'Refund for {orig_tx.reference} via {self.credit_note_id.name}',
|
||||
)
|
||||
except (ValidationError, UserError) as e:
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'status_message': str(e),
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
refund_status = result.get('status', '')
|
||||
refund_id = result.get('id', '')
|
||||
|
||||
_logger.info(
|
||||
"Clover refund response: status=%s, id=%s",
|
||||
refund_status, refund_id,
|
||||
)
|
||||
|
||||
if refund_status == 'failed':
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'status_message': _(
|
||||
"Refund declined by the payment processor. "
|
||||
"Status: %(status)s. Please try again or contact support.",
|
||||
status=refund_status,
|
||||
),
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
refund_tx = self._create_refund_transaction(orig_tx, refund_id, refund_status)
|
||||
self.refund_transaction_id = refund_tx
|
||||
|
||||
self.credit_note_id.sudo().clover_refunded = True
|
||||
|
||||
self.credit_note_id.sudo().message_post(
|
||||
body=_(
|
||||
"Refund processed via Clover. Amount: %(amount)s %(currency)s. "
|
||||
"Clover Refund ID: %(refund_id)s.",
|
||||
amount=self.amount,
|
||||
currency=self.currency_id.name,
|
||||
refund_id=refund_id,
|
||||
),
|
||||
)
|
||||
|
||||
self.write({
|
||||
'state': 'done',
|
||||
'status_message': _(
|
||||
"Refund of %(amount)s %(currency)s processed successfully. "
|
||||
"The refund will appear on the customer's card within "
|
||||
"3-5 business days.",
|
||||
amount=self.amount,
|
||||
currency=self.currency_id.name,
|
||||
),
|
||||
})
|
||||
|
||||
return self._reopen_wizard()
|
||||
|
||||
def _process_non_referenced_credit(self):
|
||||
"""Issue a non-referenced credit (manual refund).
|
||||
|
||||
Two paths:
|
||||
1. If a terminal is selected → send to terminal via Cloud Pay Display
|
||||
(card-present, customer taps/inserts card on device).
|
||||
2. If no terminal → use Clover Ecommerce ``POST /v1/credits``
|
||||
(card-not-present, Clover issues credit to original card on file).
|
||||
|
||||
Note: ``POST /v1/credits`` may not be enabled for all merchants.
|
||||
"""
|
||||
provider = self._get_provider_sudo()
|
||||
orig_tx = self.original_transaction_id
|
||||
|
||||
if self.terminal_id:
|
||||
return self._non_referenced_credit_via_terminal(provider, orig_tx)
|
||||
return self._non_referenced_credit_via_api(provider, orig_tx)
|
||||
|
||||
def _non_referenced_credit_via_api(self, provider, orig_tx):
|
||||
"""Issue a non-referenced credit via Clover Ecommerce API."""
|
||||
description = (
|
||||
f"Non-referenced credit for {orig_tx.reference} "
|
||||
f"via {self.credit_note_id.name}"
|
||||
)
|
||||
try:
|
||||
result = provider._clover_create_credit(
|
||||
amount=self.amount,
|
||||
currency=self.currency_id,
|
||||
description=description,
|
||||
)
|
||||
except (ValidationError, UserError) as e:
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'status_message': _(
|
||||
"%(error)s\n\nIf non-referenced credits are not enabled "
|
||||
"for this merchant, select a terminal and ask the customer "
|
||||
"to present their card on the device.",
|
||||
error=str(e),
|
||||
),
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
credit_id = result.get('id', '')
|
||||
credit_status = result.get('status', 'succeeded')
|
||||
|
||||
refund_tx = self._create_refund_transaction(orig_tx, credit_id, credit_status)
|
||||
self.refund_transaction_id = refund_tx
|
||||
self.credit_note_id.sudo().clover_refunded = True
|
||||
|
||||
self.credit_note_id.sudo().message_post(
|
||||
body=_(
|
||||
"Non-referenced credit issued via Clover. "
|
||||
"Amount: %(amount)s %(currency)s. "
|
||||
"Clover Credit ID: %(credit_id)s.",
|
||||
amount=self.amount,
|
||||
currency=self.currency_id.name,
|
||||
credit_id=credit_id,
|
||||
),
|
||||
)
|
||||
|
||||
self.write({
|
||||
'state': 'done',
|
||||
'status_message': _(
|
||||
"Non-referenced credit of %(amount)s %(currency)s issued "
|
||||
"successfully. The credit will appear on the customer's "
|
||||
"card within 3-5 business days.",
|
||||
amount=self.amount,
|
||||
currency=self.currency_id.name,
|
||||
),
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
def _non_referenced_credit_via_terminal(self, provider, orig_tx):
|
||||
"""Send a non-referenced credit to the terminal (card-present)."""
|
||||
minor_amount = clover_utils.format_clover_amount(
|
||||
self.amount, self.currency_id,
|
||||
)
|
||||
reference = f"NRC-{self.credit_note_id.name}"
|
||||
|
||||
payload = {
|
||||
'amount': minor_amount,
|
||||
'externalPaymentId': reference,
|
||||
}
|
||||
|
||||
try:
|
||||
provider._clover_terminal_request(
|
||||
'POST', 'payments',
|
||||
serial_number=self.terminal_id.serial_number,
|
||||
payload=payload,
|
||||
)
|
||||
except (ValidationError, UserError) as e:
|
||||
self.write({
|
||||
'state': 'error',
|
||||
'status_message': str(e),
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
refund_tx = self._create_refund_transaction(
|
||||
orig_tx, refund_id='', refund_status='PENDING',
|
||||
)
|
||||
self.refund_transaction_id = refund_tx
|
||||
self.credit_note_id.sudo().clover_refunded = True
|
||||
|
||||
self.credit_note_id.sudo().message_post(
|
||||
body=_(
|
||||
"Non-referenced credit sent to terminal '%(terminal)s'. "
|
||||
"Amount: %(amount)s %(currency)s. "
|
||||
"The customer must present their card on the terminal to "
|
||||
"complete the refund.",
|
||||
terminal=self.terminal_id.name,
|
||||
amount=self.amount,
|
||||
currency=self.currency_id.name,
|
||||
),
|
||||
)
|
||||
|
||||
self.write({
|
||||
'state': 'done',
|
||||
'status_message': _(
|
||||
"Non-referenced credit of %(amount)s %(currency)s sent to "
|
||||
"terminal '%(terminal)s'. Please ask the customer to present "
|
||||
"their card on the terminal to complete the refund.",
|
||||
amount=self.amount,
|
||||
currency=self.currency_id.name,
|
||||
terminal=self.terminal_id.name,
|
||||
),
|
||||
})
|
||||
return self._reopen_wizard()
|
||||
|
||||
def _create_refund_transaction(self, orig_tx, refund_id, refund_status):
|
||||
"""Create a payment.transaction for the refund."""
|
||||
PaymentMethod = self.env['payment.method'].sudo().with_context(active_test=False)
|
||||
payment_method = PaymentMethod.search(
|
||||
[('code', '=', 'card')], limit=1,
|
||||
) or PaymentMethod.search(
|
||||
[('code', 'in', ('visa', 'mastercard'))], limit=1,
|
||||
)
|
||||
|
||||
refund_tx = self.env['payment.transaction'].sudo().create({
|
||||
'provider_id': self.provider_id.id,
|
||||
'payment_method_id': payment_method.id if payment_method else False,
|
||||
'amount': -self.amount,
|
||||
'currency_id': self.currency_id.id,
|
||||
'partner_id': self.partner_id.id,
|
||||
'operation': 'refund',
|
||||
'source_transaction_id': orig_tx.id,
|
||||
'provider_reference': refund_id or '',
|
||||
'clover_charge_id': orig_tx.clover_charge_id,
|
||||
'clover_refund_id': refund_id or '',
|
||||
'invoice_ids': [(4, self.credit_note_id.id)],
|
||||
})
|
||||
|
||||
if refund_id and refund_status not in ('PENDING',):
|
||||
payment_data = {
|
||||
'reference': refund_tx.reference,
|
||||
'clover_charge_id': orig_tx.clover_charge_id,
|
||||
'clover_refund_id': refund_id,
|
||||
'clover_status': refund_status or 'succeeded',
|
||||
}
|
||||
refund_tx._process('clover', payment_data)
|
||||
|
||||
return refund_tx
|
||||
|
||||
def action_send_receipt(self):
|
||||
"""Email the refund receipt to the customer and close the wizard."""
|
||||
self.ensure_one()
|
||||
tx = self.refund_transaction_id
|
||||
if not tx:
|
||||
raise UserError(_("No refund transaction found."))
|
||||
|
||||
template = self.env.ref(
|
||||
'fusion_clover.mail_template_clover_receipt', raise_if_not_found=False,
|
||||
)
|
||||
if not template:
|
||||
raise UserError(_("Receipt email template not found."))
|
||||
|
||||
template.send_mail(tx.id, force_send=True)
|
||||
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
def _reopen_wizard(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _("Refund via Clover"),
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'views': [(False, 'form')],
|
||||
'target': 'new',
|
||||
}
|
||||
114
fusion_clover/wizard/clover_refund_wizard_views.xml
Normal file
114
fusion_clover/wizard/clover_refund_wizard_views.xml
Normal file
@@ -0,0 +1,114 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="clover_refund_wizard_form" model="ir.ui.view">
|
||||
<field name="name">clover.refund.wizard.form</field>
|
||||
<field name="model">clover.refund.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Refund via Clover">
|
||||
|
||||
<!-- Success banner -->
|
||||
<div class="alert alert-success text-center"
|
||||
role="status"
|
||||
invisible="state != 'done'">
|
||||
<strong>Refund Processed Successfully</strong>
|
||||
<p><field name="status_message" nolabel="1" readonly="1"/></p>
|
||||
</div>
|
||||
|
||||
<!-- Error banner -->
|
||||
<div class="alert alert-danger text-center"
|
||||
role="alert"
|
||||
invisible="state != 'error'">
|
||||
<strong>Refund Failed</strong>
|
||||
<p><field name="status_message" nolabel="1" readonly="1"/></p>
|
||||
</div>
|
||||
|
||||
<!-- Non-referenced credit warning -->
|
||||
<div class="alert alert-warning"
|
||||
role="alert"
|
||||
invisible="state != 'confirm' or refund_type != 'non_referenced'">
|
||||
<strong><i class="fa fa-exclamation-triangle"/> Non-Referenced Credit Required</strong>
|
||||
<p><field name="refund_type_note" nolabel="1" readonly="1"/></p>
|
||||
</div>
|
||||
|
||||
<!-- Referenced refund info -->
|
||||
<div class="alert alert-info"
|
||||
role="status"
|
||||
invisible="state != 'confirm' or refund_type != 'referenced'">
|
||||
<i class="fa fa-info-circle"/>
|
||||
<field name="refund_type_note" nolabel="1" readonly="1"/>
|
||||
</div>
|
||||
|
||||
<group invisible="state == 'done'">
|
||||
<group string="Refund Details">
|
||||
<field name="credit_note_id" readonly="1"/>
|
||||
<field name="original_invoice_id" readonly="1"/>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
<field name="amount" readonly="state != 'confirm'"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="refund_type" invisible="1"/>
|
||||
<field name="transaction_age_days" readonly="1"/>
|
||||
</group>
|
||||
<group string="Original Payment">
|
||||
<field name="provider_id" invisible="1"/>
|
||||
<field name="provider_name"/>
|
||||
<field name="original_transaction_id" readonly="1"/>
|
||||
<field name="original_clover_charge_id" readonly="1"/>
|
||||
<field name="card_info" readonly="1"
|
||||
invisible="not card_info"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Terminal selector for non-referenced credits -->
|
||||
<group string="Terminal (Optional)"
|
||||
invisible="state != 'confirm' or refund_type != 'non_referenced'">
|
||||
<field name="terminal_id"
|
||||
options="{'no_create': True}"/>
|
||||
<div colspan="2" class="text-muted small">
|
||||
<i class="fa fa-info-circle"/>
|
||||
Select a terminal if the customer's card is present.
|
||||
Leave empty to issue the credit via the Clover API.
|
||||
</div>
|
||||
</group>
|
||||
|
||||
<footer>
|
||||
<!-- Confirm state -->
|
||||
<button string="Process Refund"
|
||||
name="action_process_refund"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
icon="fa-undo"
|
||||
invisible="state != 'confirm'"
|
||||
confirm="Are you sure you want to refund this amount? This cannot be undone."
|
||||
data-hotkey="q"/>
|
||||
<button string="Cancel"
|
||||
class="btn-secondary"
|
||||
special="cancel"
|
||||
invisible="state != 'confirm'"
|
||||
data-hotkey="x"/>
|
||||
<!-- Done state -->
|
||||
<button string="Send Receipt"
|
||||
name="action_send_receipt"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
icon="fa-envelope"
|
||||
invisible="state != 'done'"
|
||||
data-hotkey="s"/>
|
||||
<button string="Close"
|
||||
class="btn-secondary"
|
||||
special="cancel"
|
||||
invisible="state != 'done'"
|
||||
data-hotkey="x"/>
|
||||
<!-- Error state -->
|
||||
<button string="Close"
|
||||
class="btn-primary"
|
||||
special="cancel"
|
||||
invisible="state != 'error'"
|
||||
data-hotkey="x"/>
|
||||
</footer>
|
||||
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user