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

@@ -1,5 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import json
import logging
from werkzeug.urls import url_encode
@@ -28,6 +30,23 @@ class PaymentTransaction(models.Model):
readonly=True,
copy=False,
)
poynt_receipt_data = fields.Text(
string="Poynt Receipt Data",
readonly=True,
copy=False,
help="JSON blob with receipt-relevant fields captured at payment time.",
)
poynt_voided = fields.Boolean(
string="Voided on Poynt",
readonly=True,
copy=False,
default=False,
)
poynt_void_date = fields.Datetime(
string="Voided On",
readonly=True,
copy=False,
)
# === BUSINESS METHODS - PAYMENT FLOW === #
@@ -87,6 +106,8 @@ class PaymentTransaction(models.Model):
try:
order_payload = poynt_utils.build_order_payload(
self.reference, self.amount, self.currency_id,
business_id=self.provider_id.poynt_business_id,
store_id=self.provider_id.poynt_store_id or '',
)
order_result = self.provider_id._poynt_make_request(
'POST', 'orders', payload=order_payload,
@@ -138,6 +159,8 @@ class PaymentTransaction(models.Model):
order_payload = poynt_utils.build_order_payload(
self.reference, self.amount, self.currency_id,
business_id=self.provider_id.poynt_business_id,
store_id=self.provider_id.poynt_store_id or '',
)
order_result = self.provider_id._poynt_make_request(
'POST', 'orders', payload=order_payload,
@@ -173,7 +196,12 @@ class PaymentTransaction(models.Model):
self._set_error(str(e))
def _send_refund_request(self):
"""Override of `payment` to send a refund request to Poynt."""
"""Override of `payment` to send a refund request to Poynt.
For captured/settled transactions (SALE), we look up the
CAPTURE child via HATEOAS links and use that as ``parentId``.
The ``fundingSource`` is required per Poynt docs.
"""
if self.provider_code != 'poynt':
return super()._send_refund_request()
@@ -181,10 +209,33 @@ class PaymentTransaction(models.Model):
refund_amount = abs(self.amount)
minor_amount = poynt_utils.format_poynt_amount(refund_amount, self.currency_id)
parent_txn_id = source_tx.poynt_transaction_id or source_tx.provider_reference
try:
txn_data = self.provider_id._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, source_tx.poynt_transaction_id,
)
break
except (ValidationError, Exception):
_logger.debug(
"Could not fetch parent txn %s, using original ID",
parent_txn_id,
)
try:
refund_payload = {
'action': 'REFUND',
'parentId': source_tx.provider_reference,
'parentId': parent_txn_id,
'fundingSource': {
'type': 'CREDIT_DEBIT',
},
'amounts': {
'transactionAmount': minor_amount,
'orderAmount': minor_amount,
@@ -250,35 +301,175 @@ class PaymentTransaction(models.Model):
self._set_error(str(e))
def _send_void_request(self):
"""Override of `payment` to send a void request to Poynt."""
"""Override of `payment` to send a void request to Poynt.
Uses ``POST /transactions/{transactionId}/void`` -- the dedicated
void endpoint.
"""
if self.provider_code != 'poynt':
return super()._send_void_request()
source_tx = self.source_transaction_id
txn_id = source_tx.provider_reference or source_tx.poynt_transaction_id
try:
void_payload = {
'action': 'VOID',
'parentId': source_tx.provider_reference,
'context': {
'source': 'WEB',
'sourceApp': 'odoo.fusion_poynt',
},
}
result = self.provider_id._poynt_make_request(
'POST', 'transactions', payload=void_payload,
'POST', f'transactions/{txn_id}/void',
)
payment_data = {
'reference': self.reference,
'poynt_transaction_id': result.get('id'),
'poynt_transaction_id': result.get('id', txn_id),
'poynt_status': result.get('status', 'VOIDED'),
}
self._process('poynt', payment_data)
except ValidationError as e:
self._set_error(str(e))
# === ACTION METHODS - VOID === #
def action_poynt_void(self):
"""Void a confirmed Poynt transaction (same-day, before settlement).
For SALE transactions Poynt creates an AUTHORIZE + CAPTURE pair.
Voiding the AUTHORIZE after capture is rejected by the processor,
so we first fetch the transaction, look for a CAPTURE child via
the HATEOAS ``links``, and void that instead.
On success the linked ``account.payment`` is cancelled (reversing
invoice reconciliation) and the Odoo transaction is set to
cancelled. No credit note is created because funds were never
settled.
"""
self.ensure_one()
if self.provider_code != 'poynt':
raise ValidationError(_("This action is only available for Poynt transactions."))
if self.state != 'done':
raise ValidationError(_("Only confirmed transactions can be voided."))
txn_id = self.poynt_transaction_id or self.provider_reference
if not txn_id:
raise ValidationError(_("No Poynt transaction ID found."))
existing_refund = self.env['payment.transaction'].sudo().search([
('source_transaction_id', '=', self.id),
('operation', '=', 'refund'),
('state', '=', 'done'),
], limit=1)
if existing_refund:
raise ValidationError(_(
"This transaction has already been refunded "
"(%(ref)s). A voided transaction and a refund would "
"result in a double reversal.",
ref=existing_refund.reference,
))
provider = self.sudo().provider_id
txn_data = provider._poynt_make_request('GET', f'transactions/{txn_id}')
poynt_status = txn_data.get('status', '')
if poynt_status in ('REFUNDED', 'VOIDED') or txn_data.get('voided'):
raise ValidationError(_(
"This transaction has already been %(status)s on Poynt. "
"It cannot be voided again.",
status=poynt_status.lower() if poynt_status else 'voided',
))
void_target_id = txn_id
for link in txn_data.get('links', []):
child_id = link.get('href', '')
child_rel = link.get('rel', '')
if not child_id:
continue
if child_rel == 'CAPTURE':
void_target_id = child_id
try:
child_data = provider._poynt_make_request(
'GET', f'transactions/{child_id}',
)
child_status = child_data.get('status', '')
if child_status == 'REFUNDED' or child_data.get('voided'):
raise ValidationError(_(
"A linked transaction (%(child_id)s) has already "
"been %(status)s. Voiding would cause a double "
"reversal.",
child_id=child_id,
status='refunded' if child_status == 'REFUNDED' else 'voided',
))
except ValidationError:
raise
except Exception:
continue
_logger.info(
"Voiding Poynt transaction: original=%s, target=%s",
txn_id, void_target_id,
)
already_voided = txn_data.get('voided', False)
if already_voided:
_logger.info("Transaction %s is already voided on Poynt, skipping API call.", txn_id)
result = txn_data
else:
result = provider._poynt_make_request(
'POST', f'transactions/{void_target_id}/void',
)
_logger.info(
"Poynt void response: status=%s, voided=%s, id=%s",
result.get('status'), result.get('voided'), result.get('id'),
)
is_voided = result.get('voided', False)
void_status = result.get('status', '')
if not is_voided and void_status not in ('VOIDED', 'REFUNDED'):
if void_status == 'DECLINED':
raise ValidationError(_(
"Void declined by the payment processor. This usually "
"means the batch has already settled (past the daily "
"closeout at 6:00 PM). Settled transactions cannot be "
"voided.\n\n"
"To reverse this payment, create a Credit Note on the "
"invoice and process a refund through the standard "
"Odoo workflow."
))
raise ValidationError(
_("Poynt did not confirm the void. Status: %(status)s",
status=void_status)
)
if self.payment_id:
self.payment_id.sudo().action_cancel()
self.sudo().write({
'state': 'cancel',
'poynt_voided': True,
'poynt_void_date': fields.Datetime.now(),
})
invoice = self.invoice_ids[:1]
if invoice:
invoice.sudo().message_post(
body=_(
"Payment voided: transaction %(ref)s was voided on Poynt "
"(Poynt void ID: %(void_id)s).",
ref=self.reference,
void_id=result.get('id', ''),
),
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'message': _("Transaction voided successfully on Poynt."),
'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'},
},
}
# === BUSINESS METHODS - NOTIFICATION PROCESSING === #
@api.model
@@ -311,6 +502,16 @@ class PaymentTransaction(models.Model):
return tx
def _extract_amount_data(self, payment_data):
"""Override of `payment` to skip amount validation for Poynt.
Terminal payments may include tips or rounding adjustments, so we
return None to opt out of the strict amount comparison.
"""
if self.provider_code != 'poynt':
return super()._extract_amount_data(payment_data)
return None
def _apply_updates(self, payment_data):
"""Override of `payment` to update the transaction based on Poynt data."""
if self.provider_code != 'poynt':
@@ -347,13 +548,14 @@ class PaymentTransaction(models.Model):
self._set_authorized()
elif odoo_state == 'done':
self._set_done()
if self.operation == 'refund':
self.env.ref('payment.cron_post_process_payment_tx')._trigger()
self._post_process()
self._poynt_generate_receipt(payment_data)
elif odoo_state == 'cancel':
self._set_canceled()
elif odoo_state == 'refund':
self._set_done()
self.env.ref('payment.cron_post_process_payment_tx')._trigger()
self._post_process()
self._poynt_generate_receipt(payment_data)
elif odoo_state == 'error':
error_msg = payment_data.get('error_message', _("Payment was declined by Poynt."))
self._set_error(error_msg)
@@ -366,6 +568,67 @@ class PaymentTransaction(models.Model):
_("Received data with unrecognized status: %s.", status)
)
def _create_payment(self, **extra_create_values):
"""Override to route Poynt payments directly to the bank account.
Card payments via Poynt are settled instantly -- there is no separate
bank reconciliation step. We swap the ``outstanding_account_id`` to
the journal's default (bank) account before posting so the payment
transitions to ``paid`` instead of lingering in ``in_process``.
"""
if self.provider_code != 'poynt':
return super()._create_payment(**extra_create_values)
self.ensure_one()
reference = f'{self.reference} - {self.provider_reference or ""}'
payment_method_line = self.provider_id.journal_id.inbound_payment_method_line_ids\
.filtered(lambda l: l.payment_provider_id == self.provider_id)
payment_values = {
'amount': abs(self.amount),
'payment_type': 'inbound' if self.amount > 0 else 'outbound',
'currency_id': self.currency_id.id,
'partner_id': self.partner_id.commercial_partner_id.id,
'partner_type': 'customer',
'journal_id': self.provider_id.journal_id.id,
'company_id': self.provider_id.company_id.id,
'payment_method_line_id': payment_method_line.id,
'payment_token_id': self.token_id.id,
'payment_transaction_id': self.id,
'memo': reference,
'write_off_line_vals': [],
'invoice_ids': self.invoice_ids,
**extra_create_values,
}
payment_term_lines = self.invoice_ids.line_ids.filtered(
lambda line: line.display_type == 'payment_term'
)
if payment_term_lines:
payment_values['destination_account_id'] = payment_term_lines[0].account_id.id
payment = self.env['account.payment'].create(payment_values)
bank_account = self.provider_id.journal_id.default_account_id
if bank_account and bank_account.account_type == 'asset_cash':
payment.outstanding_account_id = bank_account
payment.action_post()
self.payment_id = payment
if self.operation == self.source_transaction_id.operation:
invoices = self.source_transaction_id.invoice_ids
else:
invoices = self.invoice_ids
invoices = invoices.filtered(lambda inv: inv.state != 'cancel')
if invoices:
invoices.filtered(lambda inv: inv.state == 'draft').action_post()
(payment.move_id.line_ids + invoices.line_ids).filtered(
lambda line: line.account_id == payment.destination_account_id
and not line.reconciled
).reconcile()
return payment
def _extract_token_values(self, payment_data):
"""Override of `payment` to return token data based on Poynt data."""
if self.provider_code != 'poynt':
@@ -384,3 +647,163 @@ class PaymentTransaction(models.Model):
'payment_details': card_details.get('last4', ''),
'poynt_card_id': card_details.get('card_id', ''),
}
# === RECEIPT GENERATION === #
def _poynt_generate_receipt(self, payment_data=None):
"""Fetch transaction details from Poynt, store receipt data, generate
a PDF receipt, and attach it to the linked invoice's chatter.
This method is best-effort: failures are logged but never block
the payment flow.
"""
self.ensure_one()
if self.provider_code != 'poynt' or not self.poynt_transaction_id:
return
try:
self._poynt_store_receipt_data(payment_data)
self._poynt_attach_receipt_pdf()
self._poynt_attach_poynt_receipt()
except Exception:
_logger.exception(
"Receipt generation failed for transaction %s", self.reference,
)
def _poynt_store_receipt_data(self, payment_data=None):
"""Fetch the full Poynt transaction and persist receipt-relevant
fields in :attr:`poynt_receipt_data` as a JSON blob."""
txn_data = {}
try:
txn_data = self.provider_id._poynt_make_request(
'GET', f'transactions/{self.poynt_transaction_id}',
)
except (ValidationError, Exception):
_logger.debug(
"Could not fetch Poynt txn %s for receipt", self.poynt_transaction_id,
)
funding = payment_data.get('funding_source', {}) if payment_data else {}
if not funding:
funding = txn_data.get('fundingSource', {})
card = funding.get('card', {})
entry = funding.get('entryDetails', {})
amounts = txn_data.get('amounts', {})
processor = txn_data.get('processorResponse', {})
context = txn_data.get('context', {})
currency_name = amounts.get('currency', self.currency_id.name)
decimals = const.CURRENCY_DECIMALS.get(currency_name, 2)
receipt = {
'transaction_id': self.poynt_transaction_id,
'order_id': self.poynt_order_id or '',
'reference': self.reference,
'status': txn_data.get('status', payment_data.get('poynt_status', '') if payment_data else ''),
'created_at': txn_data.get('createdAt', ''),
'card_type': card.get('type', ''),
'card_last4': card.get('numberLast4', ''),
'card_first6': card.get('numberFirst6', ''),
'card_holder': card.get('cardHolderFullName', ''),
'entry_mode': entry.get('entryMode', ''),
'customer_presence': entry.get('customerPresenceStatus', ''),
'transaction_amount': amounts.get('transactionAmount', 0) / (10 ** decimals) if amounts.get('transactionAmount') else float(self.amount),
'tip_amount': amounts.get('tipAmount', 0) / (10 ** decimals) if amounts.get('tipAmount') else 0,
'currency': currency_name,
'approval_code': processor.get('approvalCode', txn_data.get('approvalCode', '')),
'processor': processor.get('processor', ''),
'processor_status': processor.get('status', ''),
'store_id': context.get('storeId', ''),
'device_id': context.get('storeDeviceId', ''),
}
self.poynt_receipt_data = json.dumps(receipt)
def _poynt_attach_receipt_pdf(self):
"""Render the QWeb receipt report and attach the PDF to the invoice."""
invoice = self.invoice_ids[:1]
if not invoice:
return
try:
report = self.env.ref('fusion_poynt.action_report_poynt_receipt')
pdf_content, _report_type = report._render_qweb_pdf(report.report_name, [self.id])
except Exception:
_logger.debug("Could not render Poynt receipt PDF for %s", self.reference)
return
filename = f"Payment_Receipt_{self.reference}.pdf"
attachment = self.env['ir.attachment'].sudo().create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'account.move',
'res_id': invoice.id,
'mimetype': 'application/pdf',
})
invoice.sudo().message_post(
body=_(
"Payment receipt generated for transaction %(ref)s.",
ref=self.reference,
),
attachment_ids=[attachment.id],
)
def _poynt_attach_poynt_receipt(self):
"""Try the Poynt renderReceipt endpoint and attach the result."""
invoice = self.invoice_ids[:1]
if not invoice:
return
receipt_content = self.provider_id._poynt_fetch_receipt(
self.poynt_transaction_id,
)
if not receipt_content:
return
filename = f"Poynt_Receipt_{self.reference}.html"
self.env['ir.attachment'].sudo().create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(receipt_content.encode('utf-8')),
'res_model': 'account.move',
'res_id': invoice.id,
'mimetype': 'text/html',
})
def _get_poynt_receipt_values(self):
"""Parse the stored receipt JSON for use in QWeb templates.
For refund transactions that lack their own receipt data, this
falls back to the source (sale) transaction's receipt data so the
card details and approval codes are still available.
:return: Dict of receipt values or empty dict.
:rtype: dict
"""
self.ensure_one()
data = self.poynt_receipt_data
if not data and self.source_transaction_id:
data = self.source_transaction_id.poynt_receipt_data
if not data:
return {}
try:
return json.loads(data)
except (json.JSONDecodeError, TypeError):
return {}
def _get_source_receipt_values(self):
"""Return receipt values from the original sale transaction.
Used by the refund receipt template to render the original sale
details on a second page.
"""
self.ensure_one()
if self.source_transaction_id and self.source_transaction_id.poynt_receipt_data:
try:
return json.loads(self.source_transaction_id.poynt_receipt_data)
except (json.JSONDecodeError, TypeError):
pass
return {}