553 lines
19 KiB
Python
553 lines
19 KiB
Python
# 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,
|
|
},
|
|
}
|