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