532 lines
18 KiB
Python
532 lines
18 KiB
Python
# 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,
|
|
)
|
|
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)
|
|
|
|
@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
|
|
|
|
if orig_tx.provider_id.poynt_default_terminal_id:
|
|
res['terminal_id'] = orig_tx.provider_id.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.provider_id
|
|
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.provider_id
|
|
|
|
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.provider_id
|
|
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',
|
|
}
|