# Part of Odoo. See LICENSE file for full copyright and licensing details. import json import logging from datetime import timedelta from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError from odoo.addons.fusion_poynt import utils as poynt_utils _logger = logging.getLogger(__name__) REFERENCED_REFUND_LIMIT_DAYS = 180 class PoyntRefundWizard(models.TransientModel): _name = 'poynt.refund.wizard' _description = 'Refund via Poynt' 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="Poynt Provider", required=True, readonly=True, ) provider_name = fields.Char( related='provider_id.name', string="Poynt Provider", readonly=True, ) original_transaction_id = fields.Many2one( 'payment.transaction', string="Original Transaction", readonly=True, ) original_poynt_txn_id = fields.Char( string="Poynt Transaction ID", readonly=True, ) card_info = fields.Char( string="Card Used", readonly=True, ) 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( 'poynt.terminal', string="Terminal", domain="[('provider_id', '=', provider_id), ('active', '=', True)]", help="Terminal to process the non-referenced credit on.", ) 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_poynt_transaction() if not orig_tx: raise UserError(_( "No Poynt payment transaction found for the original invoice. " "This credit note cannot be refunded via Poynt." )) 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_poynt_txn_id'] = orig_tx.poynt_transaction_id provider = orig_tx.provider_id.sudo() if provider.poynt_default_terminal_id: res['terminal_id'] = provider.poynt_default_terminal_id.id 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 > 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=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=REFERENCED_REFUND_LIMIT_DAYS, ) receipt_data = orig_tx.poynt_receipt_data if receipt_data: try: data = json.loads(receipt_data) card_type = data.get('card_type', '') card_last4 = data.get('card_last4', '') if card_type or card_last4: res['card_info'] = f"{card_type} ****{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.poynt_voided: raise UserError(_( "This transaction was already voided on Poynt on %(date)s. " "A voided transaction cannot also be refunded -- the charge " "was already reversed before settlement.", date=orig_tx.poynt_void_date, )) self._verify_transaction_not_already_reversed() if self.refund_type == 'non_referenced': return self._process_non_referenced_credit() return self._process_referenced_refund() def _verify_transaction_not_already_reversed(self): """Check on Poynt that the transaction and all linked children have not been voided or refunded. For SALE transactions Poynt creates AUTHORIZE + CAPTURE children. A void/refund may target the capture child, leaving the parent still showing ``status: CAPTURED``. We must check the full chain. """ orig_tx = self.original_transaction_id provider = self._get_provider_sudo() txn_id = orig_tx.poynt_transaction_id try: txn_data = provider._poynt_make_request( 'GET', f'transactions/{txn_id}', ) except (ValidationError, Exception): _logger.debug("Could not verify transaction %s on Poynt", txn_id) return self._check_txn_reversed(txn_data, orig_tx) for link in txn_data.get('links', []): child_id = link.get('href', '') if not child_id: continue try: child_data = provider._poynt_make_request( 'GET', f'transactions/{child_id}', ) self._check_txn_reversed(child_data, orig_tx) except (ValidationError, Exception): continue def _check_txn_reversed(self, txn_data, orig_tx): """Raise if the given Poynt transaction has been voided or refunded.""" txn_id = txn_data.get('id', '?') if txn_data.get('voided'): _logger.warning( "Poynt txn %s is voided, blocking refund", txn_id, ) if not orig_tx.poynt_voided: orig_tx.sudo().write({ 'state': 'cancel', 'poynt_voided': True, 'poynt_void_date': fields.Datetime.now(), }) raise UserError(_( "This transaction (%(txn_id)s) has already been voided on " "Poynt. The charge was reversed before settlement -- no " "refund is needed.", txn_id=txn_id, )) status = txn_data.get('status', '') if status == 'REFUNDED': _logger.warning( "Poynt txn %s is already refunded, blocking duplicate", txn_id, ) raise UserError(_( "This transaction (%(txn_id)s) has already been refunded on " "Poynt. A duplicate refund cannot be issued.", txn_id=txn_id, )) if status == 'VOIDED': _logger.warning( "Poynt txn %s has VOIDED status, blocking refund", txn_id, ) if not orig_tx.poynt_voided: orig_tx.sudo().write({ 'state': 'cancel', 'poynt_voided': True, 'poynt_void_date': fields.Datetime.now(), }) raise UserError(_( "This transaction (%(txn_id)s) has already been voided on " "Poynt. The charge was reversed before settlement -- no " "refund is needed.", txn_id=txn_id, )) def _process_referenced_refund(self): """Send a referenced REFUND using the original transaction's parentId.""" orig_tx = self.original_transaction_id provider = self._get_provider_sudo() parent_txn_id = orig_tx.poynt_transaction_id try: txn_data = provider._poynt_make_request( 'GET', f'transactions/{parent_txn_id}', ) for link in txn_data.get('links', []): if link.get('rel') == 'CAPTURE' and link.get('href'): parent_txn_id = link['href'] _logger.info( "Refund: using captureId %s instead of original %s", parent_txn_id, orig_tx.poynt_transaction_id, ) break except (ValidationError, Exception): _logger.debug( "Could not fetch parent txn %s, using original ID", parent_txn_id, ) minor_amount = poynt_utils.format_poynt_amount( self.amount, self.currency_id, ) refund_payload = { 'action': 'REFUND', 'parentId': parent_txn_id, 'fundingSource': { 'type': 'CREDIT_DEBIT', }, 'amounts': { 'transactionAmount': minor_amount, 'orderAmount': minor_amount, 'currency': self.currency_id.name, }, 'context': { 'source': 'WEB', 'sourceApp': 'odoo.fusion_poynt', }, 'notes': f'Refund for {orig_tx.reference} via {self.credit_note_id.name}', } try: result = provider._poynt_make_request( 'POST', 'transactions', payload=refund_payload, ) except (ValidationError, UserError) as e: self.write({ 'state': 'error', 'status_message': str(e), }) return self._reopen_wizard() return self._handle_refund_result(result, orig_tx) def _process_non_referenced_credit(self): """Send a non-referenced credit via cloud message to the terminal. Required when the original transaction is older than 180 days. The customer's card must be present on the terminal. """ if not self.terminal_id: raise UserError(_( "A terminal is required for non-referenced credits. " "The customer's card must be present on the device." )) provider = self._get_provider_sudo() orig_tx = self.original_transaction_id minor_amount = poynt_utils.format_poynt_amount( self.amount, self.currency_id, ) reference = f"NRC-{self.credit_note_id.name}" payment_data = json.dumps({ 'amount': minor_amount, 'currency': self.currency_id.name, 'nonReferencedCredit': True, 'referenceId': reference, 'callbackUrl': '', }) cloud_payload = { 'ttl': 120, 'businessId': provider.poynt_business_id, 'storeId': provider.poynt_store_id, 'deviceId': self.terminal_id.terminal_id, 'data': json.dumps({ 'action': 'non-referenced-credit', 'purchaseAmount': minor_amount, 'currency': self.currency_id.name, 'referenceId': reference, 'payment': payment_data, }), } try: provider._poynt_make_request( 'POST', 'cloudMessages', payload=cloud_payload, business_scoped=False, ) 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_txn_id='', refund_status='PENDING', ) self.credit_note_id.sudo().poynt_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 _handle_refund_result(self, result, orig_tx): """Process the Poynt API response for a referenced refund.""" refund_status = result.get('status', '') refund_txn_id = result.get('id', '') _logger.info( "Poynt refund response: status=%s, id=%s", refund_status, refund_txn_id, ) if refund_status in ('DECLINED', '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_txn_id, refund_status) self.refund_transaction_id = refund_tx self.credit_note_id.sudo().poynt_refunded = True self.credit_note_id.sudo().message_post( body=_( "Refund processed via Poynt. Amount: %(amount)s %(currency)s. " "Poynt Transaction ID: %(txn_id)s.", amount=self.amount, currency=self.currency_id.name, txn_id=refund_txn_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 _create_refund_transaction(self, orig_tx, refund_txn_id, refund_status): """Create a payment.transaction for the refund.""" payment_method = self.env['payment.method'].search( [('code', '=', 'card')], limit=1, ) or self.env['payment.method'].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_txn_id or '', 'poynt_transaction_id': refund_txn_id or '', 'invoice_ids': [(4, self.credit_note_id.id)], }) if refund_txn_id and refund_status not in ('PENDING',): payment_data = { 'reference': refund_tx.reference, 'poynt_transaction_id': refund_txn_id, 'poynt_status': refund_status, } refund_tx._process('poynt', 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_poynt.mail_template_poynt_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 Poynt"), 'res_model': self._name, 'res_id': self.id, 'views': [(False, 'form')], 'target': 'new', }