This commit is contained in:
gsinghpal
2026-02-25 09:40:41 -05:00
parent 0e1aebe60b
commit e71bc503f9
69 changed files with 7537 additions and 82 deletions

View File

@@ -0,0 +1,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import poynt_payment_wizard
from . import poynt_refund_wizard

View File

@@ -0,0 +1,552 @@
# 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_poynt import utils as poynt_utils
_logger = logging.getLogger(__name__)
class PoyntPaymentWizard(models.TransientModel):
_name = 'poynt.payment.wizard'
_description = 'Collect Poynt 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="Poynt Provider",
required=True,
domain="[('code', '=', 'poynt'), ('state', '!=', 'disabled')]",
)
payment_mode = fields.Selection(
selection=[
('terminal', "Send to Terminal"),
('card', "Manual Card Entry"),
],
string="Payment Mode",
required=True,
default='terminal',
)
# --- Terminal fields ---
terminal_id = fields.Many2one(
'poynt.terminal',
string="Terminal",
domain="[('provider_id', '=', provider_id), ('active', '=', 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 for terminal mode ---
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)
poynt_transaction_ref = fields.Char(readonly=True)
poynt_order_id = fields.Char(
string="Poynt Order ID",
readonly=True,
help="The Poynt order UUID created for this payment attempt. "
"Used to verify the correct terminal transaction.",
)
sent_at = fields.Datetime(
string="Sent At",
readonly=True,
help="Timestamp when the payment was sent to the terminal. "
"Used to filter out older transactions during status checks.",
)
transaction_id = fields.Many2one(
'payment.transaction',
string="Payment Transaction",
readonly=True,
)
@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['currency_id'] = invoice.currency_id.id
provider = self.env['payment.provider'].search([
('code', '=', 'poynt'),
('state', '!=', 'disabled'),
], limit=1)
if provider:
res['provider_id'] = provider.id
if provider.poynt_default_terminal_id:
res['terminal_id'] = provider.poynt_default_terminal_id.id
return res
@api.onchange('provider_id')
def _onchange_provider_id(self):
if self.provider_id and self.provider_id.poynt_default_terminal_id:
self.terminal_id = self.provider_id.poynt_default_terminal_id
def action_collect_payment(self):
"""Dispatch to the appropriate payment method."""
self.ensure_one()
if self.amount <= 0:
raise UserError(_("Payment amount must be greater than zero."))
self._cleanup_draft_transaction()
if self.payment_mode == 'terminal':
return self._process_terminal_payment()
elif self.payment_mode == 'card':
return self._process_card_payment()
def _process_terminal_payment(self):
"""Send a payment request to the physical Poynt terminal."""
self.ensure_one()
if not self.terminal_id:
raise UserError(_("Please select a terminal."))
tx = self._create_payment_transaction()
reference = tx.reference
try:
order_data = self._create_poynt_order(reference)
order_id = order_data.get('id', '')
tx.poynt_order_id = order_id
self.terminal_id.action_send_payment_to_terminal(
amount=self.amount,
currency=self.currency_id,
reference=reference,
order_id=order_id,
)
self.write({
'state': 'waiting',
'status_message': _(
"Payment request sent to terminal '%(terminal)s'. "
"Please complete the transaction on the device.",
terminal=self.terminal_id.name,
),
'poynt_transaction_ref': reference,
'poynt_order_id': order_id,
'sent_at': fields.Datetime.now(),
})
return self._open_poll_action()
except (ValidationError, UserError) as e:
self._cleanup_draft_transaction()
self.write({
'state': 'error',
'status_message': str(e),
})
return self._reopen_wizard()
def _process_card_payment(self):
"""Process a manual card entry payment via Poynt Cloud API."""
self.ensure_one()
self._validate_card_fields()
tx = self._create_payment_transaction()
reference = tx.reference
try:
order_data = self._create_poynt_order(reference)
order_id = order_data.get('id', '')
tx.poynt_order_id = order_id
funding_source = {
'type': 'CREDIT_DEBIT',
'card': {
'number': self.card_number.replace(' ', ''),
'expirationMonth': int(self.exp_month),
'expirationYear': int(self.exp_year),
'cardHolderFullName': self.cardholder_name or '',
},
'verificationData': {
'cvData': self.cvv,
},
'entryDetails': {
'customerPresenceStatus': 'MOTO',
'entryMode': 'KEYED',
},
}
action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE'
minor_amount = poynt_utils.format_poynt_amount(
self.amount, self.currency_id,
)
txn_payload = {
'action': action,
'amounts': {
'transactionAmount': minor_amount,
'orderAmount': minor_amount,
'tipAmount': 0,
'cashbackAmount': 0,
'currency': self.currency_id.name,
},
'fundingSource': funding_source,
'context': {
'source': 'WEB',
'sourceApp': 'odoo.fusion_poynt',
'transactionInstruction': 'ONLINE_AUTH_REQUIRED',
'businessId': self.provider_id.poynt_business_id,
},
'notes': reference,
}
if order_id:
txn_payload['references'] = [{
'id': order_id,
'type': 'POYNT_ORDER',
}]
result = self.provider_id._poynt_make_request(
'POST', 'transactions', payload=txn_payload,
)
transaction_id = result.get('id', '')
status = result.get('status', '')
tx.write({
'poynt_transaction_id': transaction_id,
'provider_reference': transaction_id,
})
payment_data = {
'reference': reference,
'poynt_transaction_id': transaction_id,
'poynt_order_id': order_id,
'poynt_status': status,
'funding_source': result.get('fundingSource', {}),
}
tx._process('poynt', payment_data)
self.write({
'state': 'done',
'status_message': _(
"Payment collected successfully. Transaction: %(txn_id)s",
txn_id=transaction_id,
),
'poynt_transaction_ref': transaction_id,
})
return self._reopen_wizard()
except (ValidationError, UserError) as e:
self._cleanup_draft_transaction()
self.write({
'state': 'error',
'status_message': str(e),
})
return self._reopen_wizard()
def action_check_status(self):
"""Poll the terminal for payment status (used in waiting state).
Uses the Poynt order ID to look up transactions associated with
the specific order we created for this payment attempt. Falls
back to referenceId search with time filtering to avoid matching
transactions from previous attempts.
Returns False if still pending (JS poller keeps going), or an
act_window action to show the final result.
"""
self.ensure_one()
if not self.poynt_transaction_ref:
raise UserError(_("No payment reference to check."))
terminal = self.terminal_id
if not terminal:
raise UserError(_("No terminal associated with this payment."))
provider = self.provider_id
try:
txn = self._find_terminal_transaction(provider)
except (ValidationError, UserError):
return False
if not txn:
return False
status = txn.get('status', 'UNKNOWN')
transaction_id = txn.get('id', '')
if status in ('CAPTURED', 'AUTHORIZED', 'SETTLED'):
tx = self.transaction_id
if tx:
tx.write({
'poynt_transaction_id': transaction_id,
'provider_reference': transaction_id,
})
payment_data = {
'reference': self.poynt_transaction_ref,
'poynt_transaction_id': transaction_id,
'poynt_status': status,
'funding_source': txn.get('fundingSource', {}),
}
tx._process('poynt', payment_data)
self.write({
'state': 'done',
'status_message': _(
"Payment collected successfully on terminal."
),
})
return self._reopen_wizard()
if status in ('DECLINED', 'VOIDED', 'REFUNDED'):
self._cleanup_draft_transaction()
self.write({
'state': 'error',
'status_message': _(
"Payment was %(status)s on the terminal.",
status=status.lower(),
),
})
return self._reopen_wizard()
return False
def _find_terminal_transaction(self, provider):
"""Locate the Poynt transaction for the current payment attempt.
Strategy:
1. If we have a poynt_order_id, fetch the order from Poynt and
check its linked transactions for a completed payment.
2. Fall back to searching transactions by referenceId, but only
accept transactions created after we sent the cloud message
(self.sent_at) to avoid matching older transactions.
:return: The matching Poynt transaction dict, or None if pending.
:rtype: dict | None
"""
if self.poynt_order_id:
txn = self._check_order_transactions(provider)
if txn:
return txn
return self._check_by_reference_with_time_filter(provider)
def _check_order_transactions(self, provider):
"""Look up transactions linked to our Poynt order."""
try:
order_data = provider._poynt_make_request(
'GET', f'orders/{self.poynt_order_id}',
)
except (ValidationError, UserError):
_logger.debug("Could not fetch Poynt order %s", self.poynt_order_id)
return None
txn_ids = []
for item in order_data.get('transactions', []):
tid = item if isinstance(item, str) else item.get('id', '')
if tid:
txn_ids.append(tid)
for tid in txn_ids:
try:
txn_data = provider._poynt_make_request(
'GET', f'transactions/{tid}',
)
status = txn_data.get('status', '')
if status in ('CAPTURED', 'AUTHORIZED', 'SETTLED',
'DECLINED', 'VOIDED', 'REFUNDED'):
return txn_data
except (ValidationError, UserError):
continue
return None
def _check_by_reference_with_time_filter(self, provider):
"""Search transactions by referenceId, filtering by creation time."""
sent_at_str = ''
if self.sent_at:
sent_at_str = self.sent_at.strftime('%Y-%m-%dT%H:%M:%SZ')
params = {
'referenceId': self.poynt_transaction_ref,
'limit': 5,
}
if sent_at_str:
params['startAt'] = sent_at_str
try:
txn_result = provider._poynt_make_request(
'GET', 'transactions', params=params,
)
except (ValidationError, UserError):
return None
transactions = txn_result.get('transactions', [])
if not transactions and not sent_at_str:
try:
txn_result = provider._poynt_make_request(
'GET', 'transactions',
params={'notes': self.poynt_transaction_ref, 'limit': 5},
)
transactions = txn_result.get('transactions', [])
except (ValidationError, UserError):
return None
for txn in transactions:
status = txn.get('status', 'UNKNOWN')
if status in ('CAPTURED', 'AUTHORIZED', 'SETTLED',
'DECLINED', 'VOIDED', 'REFUNDED'):
return txn
return None
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_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 action_cancel_payment(self):
"""Cancel the payment and clean up the draft transaction."""
self.ensure_one()
self._cleanup_draft_transaction()
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."""
payment_method = self.env['payment.method'].search(
[('code', '=', 'card')], limit=1,
)
if not payment_method:
payment_method = self.env['payment.method'].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 _create_poynt_order(self, reference):
"""Create a Poynt order via the API."""
order_payload = poynt_utils.build_order_payload(
reference,
self.amount,
self.currency_id,
business_id=self.provider_id.poynt_business_id,
store_id=self.provider_id.poynt_store_id or '',
)
return self.provider_id._poynt_make_request(
'POST', 'orders', payload=order_payload,
)
def _reopen_wizard(self):
"""Return an action that re-opens this wizard record (keeps state)."""
return {
'type': 'ir.actions.act_window',
'name': _("Collect Poynt Payment"),
'res_model': self._name,
'res_id': self.id,
'views': [(False, 'form')],
'target': 'new',
}
def _open_poll_action(self):
"""Return a client action that auto-polls the terminal status."""
return {
'type': 'ir.actions.client',
'tag': 'poynt_poll_action',
'name': _("Waiting for Terminal"),
'target': 'new',
'params': {
'wizard_id': self.id,
},
}

View File

@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="poynt_payment_wizard_form" model="ir.ui.view">
<field name="name">poynt.payment.wizard.form</field>
<field name="model">poynt.payment.wizard</field>
<field name="arch" type="xml">
<form string="Collect Poynt Payment">
<field name="state" invisible="1"/>
<field name="poynt_transaction_ref" 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_id"
readonly="state != 'draft'"/>
</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>
<!-- 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>

View File

@@ -0,0 +1,531 @@
# 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',
}

View File

@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="poynt_refund_wizard_form" model="ir.ui.view">
<field name="name">poynt.refund.wizard.form</field>
<field name="model">poynt.refund.wizard</field>
<field name="arch" type="xml">
<form string="Refund via Poynt">
<!-- 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 text-center"
role="alert"
invisible="state != 'confirm' or refund_type != 'non_referenced'">
<strong>Non-Referenced Credit Required</strong>
<p>
The original transaction is over 180 days old.
The customer's card must be physically present on the
terminal to process this refund.
</p>
</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"/>
</group>
<group string="Original Payment">
<field name="provider_id" readonly="1"/>
<field name="original_transaction_id" readonly="1"/>
<field name="original_poynt_txn_id" readonly="1"/>
<field name="card_info" readonly="1"
invisible="not card_info"/>
<field name="transaction_age_days" readonly="1"/>
<field name="refund_type" readonly="1"/>
</group>
</group>
<!-- Terminal selector for non-referenced credits -->
<group invisible="state != 'confirm' or refund_type != 'non_referenced'">
<group string="Terminal">
<field name="terminal_id"
required="refund_type == 'non_referenced'"
options="{'no_create': True}"/>
</group>
</group>
<!-- Refund method note -->
<group invisible="state != 'confirm'">
<field name="refund_type_note" readonly="1" nolabel="1"
widget="text" colspan="2"/>
</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>