Files
Odoo-Modules/fusion_clover/wizard/clover_refund_wizard.py
gsinghpal 92369be6e0 changes
2026-03-20 11:46:41 -04:00

477 lines
17 KiB
Python

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