810 lines
30 KiB
Python
810 lines
30 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import base64
|
|
import json
|
|
import logging
|
|
|
|
from werkzeug.urls import url_encode
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import ValidationError
|
|
from odoo.tools.urls import urljoin as url_join
|
|
|
|
from odoo.addons.fusion_poynt import const
|
|
from odoo.addons.fusion_poynt import utils as poynt_utils
|
|
from odoo.addons.fusion_poynt.controllers.main import PoyntController
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PaymentTransaction(models.Model):
|
|
_inherit = 'payment.transaction'
|
|
|
|
poynt_order_id = fields.Char(
|
|
string="Poynt Order ID",
|
|
readonly=True,
|
|
copy=False,
|
|
)
|
|
poynt_transaction_id = fields.Char(
|
|
string="Poynt Transaction ID",
|
|
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 === #
|
|
|
|
def _get_specific_processing_values(self, processing_values):
|
|
"""Override of payment to return Poynt-specific processing values.
|
|
|
|
For direct (online) payments we create a Poynt order upfront and return
|
|
identifiers plus the return URL so the frontend JS can complete the flow.
|
|
"""
|
|
if self.provider_code != 'poynt':
|
|
return super()._get_specific_processing_values(processing_values)
|
|
|
|
if self.operation == 'online_token':
|
|
return {}
|
|
|
|
poynt_data = self._poynt_create_order_and_authorize()
|
|
|
|
base_url = self.provider_id.get_base_url()
|
|
return_url = url_join(
|
|
base_url,
|
|
f'{PoyntController._return_url}?{url_encode({"reference": self.reference})}',
|
|
)
|
|
|
|
return {
|
|
'poynt_order_id': poynt_data.get('order_id', ''),
|
|
'poynt_transaction_id': poynt_data.get('transaction_id', ''),
|
|
'return_url': return_url,
|
|
'business_id': self.provider_id.poynt_business_id,
|
|
'is_test': self.provider_id.state == 'test',
|
|
}
|
|
|
|
def _send_payment_request(self):
|
|
"""Override of `payment` to send a payment request to Poynt."""
|
|
if self.provider_code != 'poynt':
|
|
return super()._send_payment_request()
|
|
|
|
if self.operation in ('online_token', 'offline'):
|
|
return self._poynt_process_token_payment()
|
|
|
|
poynt_data = self._poynt_create_order_and_authorize()
|
|
if poynt_data:
|
|
payment_data = {
|
|
'reference': self.reference,
|
|
'poynt_order_id': poynt_data.get('order_id'),
|
|
'poynt_transaction_id': poynt_data.get('transaction_id'),
|
|
'poynt_status': poynt_data.get('status', 'AUTHORIZED'),
|
|
'funding_source': poynt_data.get('funding_source', {}),
|
|
}
|
|
self._process('poynt', payment_data)
|
|
|
|
def _poynt_create_order_and_authorize(self):
|
|
"""Create a Poynt order and authorize the transaction.
|
|
|
|
:return: Dict with order_id, transaction_id, status, and funding_source.
|
|
:rtype: dict
|
|
"""
|
|
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,
|
|
)
|
|
order_id = order_result.get('id', '')
|
|
self.poynt_order_id = order_id
|
|
|
|
action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE'
|
|
txn_payload = poynt_utils.build_transaction_payload(
|
|
action=action,
|
|
amount=self.amount,
|
|
currency=self.currency_id,
|
|
order_id=order_id,
|
|
reference=self.reference,
|
|
)
|
|
txn_result = self.provider_id._poynt_make_request(
|
|
'POST', 'transactions', payload=txn_payload,
|
|
)
|
|
|
|
transaction_id = txn_result.get('id', '')
|
|
self.poynt_transaction_id = transaction_id
|
|
self.provider_reference = transaction_id
|
|
|
|
return {
|
|
'order_id': order_id,
|
|
'transaction_id': transaction_id,
|
|
'status': txn_result.get('status', ''),
|
|
'funding_source': txn_result.get('fundingSource', {}),
|
|
}
|
|
except ValidationError as e:
|
|
self._set_error(str(e))
|
|
return {}
|
|
|
|
def _poynt_process_token_payment(self):
|
|
"""Process a payment using a stored token (card on file).
|
|
|
|
For token-based payments we send a SALE or AUTHORIZE using the
|
|
stored card ID from the payment token.
|
|
"""
|
|
try:
|
|
action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE'
|
|
|
|
funding_source = {
|
|
'type': 'CREDIT_DEBIT',
|
|
'card': {
|
|
'cardId': self.token_id.poynt_card_id,
|
|
},
|
|
}
|
|
|
|
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,
|
|
)
|
|
order_id = order_result.get('id', '')
|
|
self.poynt_order_id = order_id
|
|
|
|
txn_payload = poynt_utils.build_transaction_payload(
|
|
action=action,
|
|
amount=self.amount,
|
|
currency=self.currency_id,
|
|
order_id=order_id,
|
|
reference=self.reference,
|
|
funding_source=funding_source,
|
|
)
|
|
txn_result = self.provider_id._poynt_make_request(
|
|
'POST', 'transactions', payload=txn_payload,
|
|
)
|
|
|
|
transaction_id = txn_result.get('id', '')
|
|
self.poynt_transaction_id = transaction_id
|
|
self.provider_reference = transaction_id
|
|
|
|
payment_data = {
|
|
'reference': self.reference,
|
|
'poynt_order_id': order_id,
|
|
'poynt_transaction_id': transaction_id,
|
|
'poynt_status': txn_result.get('status', ''),
|
|
'funding_source': txn_result.get('fundingSource', {}),
|
|
}
|
|
self._process('poynt', payment_data)
|
|
except ValidationError as e:
|
|
self._set_error(str(e))
|
|
|
|
def _send_refund_request(self):
|
|
"""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()
|
|
|
|
source_tx = self.source_transaction_id
|
|
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': 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 {source_tx.reference}',
|
|
}
|
|
|
|
result = self.provider_id._poynt_make_request(
|
|
'POST', 'transactions', payload=refund_payload,
|
|
)
|
|
|
|
self.provider_reference = result.get('id', '')
|
|
self.poynt_transaction_id = result.get('id', '')
|
|
|
|
payment_data = {
|
|
'reference': self.reference,
|
|
'poynt_transaction_id': result.get('id'),
|
|
'poynt_status': result.get('status', 'REFUNDED'),
|
|
}
|
|
self._process('poynt', payment_data)
|
|
except ValidationError as e:
|
|
self._set_error(str(e))
|
|
|
|
def _send_capture_request(self):
|
|
"""Override of `payment` to send a capture request to Poynt."""
|
|
if self.provider_code != 'poynt':
|
|
return super()._send_capture_request()
|
|
|
|
source_tx = self.source_transaction_id
|
|
minor_amount = poynt_utils.format_poynt_amount(self.amount, self.currency_id)
|
|
|
|
try:
|
|
capture_payload = {
|
|
'action': 'CAPTURE',
|
|
'parentId': source_tx.provider_reference,
|
|
'amounts': {
|
|
'transactionAmount': minor_amount,
|
|
'orderAmount': minor_amount,
|
|
'currency': self.currency_id.name,
|
|
},
|
|
'context': {
|
|
'source': 'WEB',
|
|
'sourceApp': 'odoo.fusion_poynt',
|
|
},
|
|
}
|
|
|
|
result = self.provider_id._poynt_make_request(
|
|
'POST', 'transactions', payload=capture_payload,
|
|
)
|
|
|
|
payment_data = {
|
|
'reference': self.reference,
|
|
'poynt_transaction_id': result.get('id'),
|
|
'poynt_status': result.get('status', 'CAPTURED'),
|
|
}
|
|
self._process('poynt', payment_data)
|
|
except ValidationError as e:
|
|
self._set_error(str(e))
|
|
|
|
def _send_void_request(self):
|
|
"""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:
|
|
result = self.provider_id._poynt_make_request(
|
|
'POST', f'transactions/{txn_id}/void',
|
|
)
|
|
|
|
payment_data = {
|
|
'reference': self.reference,
|
|
'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
|
|
def _search_by_reference(self, provider_code, payment_data):
|
|
"""Override of payment to find the transaction based on Poynt data."""
|
|
if provider_code != 'poynt':
|
|
return super()._search_by_reference(provider_code, payment_data)
|
|
|
|
reference = payment_data.get('reference')
|
|
if reference:
|
|
tx = self.search([
|
|
('reference', '=', reference),
|
|
('provider_code', '=', 'poynt'),
|
|
])
|
|
else:
|
|
poynt_txn_id = payment_data.get('poynt_transaction_id')
|
|
if poynt_txn_id:
|
|
tx = self.search([
|
|
('poynt_transaction_id', '=', poynt_txn_id),
|
|
('provider_code', '=', 'poynt'),
|
|
])
|
|
else:
|
|
_logger.warning("Received Poynt data with no reference or transaction ID")
|
|
tx = self
|
|
|
|
if not tx:
|
|
_logger.warning(
|
|
"No transaction found matching Poynt reference %s", reference,
|
|
)
|
|
|
|
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':
|
|
return super()._apply_updates(payment_data)
|
|
|
|
poynt_txn_id = payment_data.get('poynt_transaction_id')
|
|
if poynt_txn_id:
|
|
self.provider_reference = poynt_txn_id
|
|
self.poynt_transaction_id = poynt_txn_id
|
|
|
|
poynt_order_id = payment_data.get('poynt_order_id')
|
|
if poynt_order_id:
|
|
self.poynt_order_id = poynt_order_id
|
|
|
|
funding_source = payment_data.get('funding_source', {})
|
|
if funding_source:
|
|
card_details = poynt_utils.extract_card_details(funding_source)
|
|
if card_details.get('brand'):
|
|
payment_method = self.env['payment.method']._get_from_code(
|
|
card_details['brand'],
|
|
mapping=const.CARD_BRAND_MAPPING,
|
|
)
|
|
if payment_method:
|
|
self.payment_method_id = payment_method
|
|
|
|
status = payment_data.get('poynt_status', '')
|
|
if not status:
|
|
self._set_error(_("Received data with missing transaction status."))
|
|
return
|
|
|
|
odoo_state = poynt_utils.get_poynt_status(status)
|
|
|
|
if odoo_state == 'authorized':
|
|
self._set_authorized()
|
|
elif odoo_state == 'done':
|
|
self._set_done()
|
|
self._post_process()
|
|
self._poynt_generate_receipt(payment_data)
|
|
elif odoo_state == 'cancel':
|
|
self._set_canceled()
|
|
elif odoo_state == 'refund':
|
|
self._set_done()
|
|
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)
|
|
else:
|
|
_logger.warning(
|
|
"Received unknown Poynt status (%s) for transaction %s.",
|
|
status, self.reference,
|
|
)
|
|
self._set_error(
|
|
_("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':
|
|
return super()._extract_token_values(payment_data)
|
|
|
|
funding_source = payment_data.get('funding_source', {})
|
|
card_details = poynt_utils.extract_card_details(funding_source)
|
|
|
|
if not card_details:
|
|
_logger.warning(
|
|
"Tokenization requested but no card data in payment response."
|
|
)
|
|
return {}
|
|
|
|
return {
|
|
'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 {}
|