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