Files
Odoo-Modules/fusion_poynt/models/payment_transaction.py
gsinghpal 41d0908ade changes
2026-04-24 21:04:38 -04:00

1090 lines
41 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,
)
def _get_provider_sudo(self):
return self.provider_id.sudo()
# === 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.
The actual transaction is created later when the frontend sends card
details via the /payment/poynt/process_card route.
"""
if self.provider_code != 'poynt':
return super()._get_specific_processing_values(processing_values)
if self.operation == 'online_token':
return {}
order_data = self._poynt_create_order()
provider = self._get_provider_sudo()
base_url = provider.get_base_url()
return_url = url_join(
base_url,
f'{PoyntController._return_url}?{url_encode({"reference": self.reference})}',
)
return {
'poynt_order_id': order_data.get('order_id', ''),
'return_url': return_url,
'business_id': provider.poynt_business_id,
'is_test': provider.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:
status = poynt_data.get('status', 'AUTHORIZED')
payment_data = {
'reference': self.reference,
'poynt_order_id': poynt_data.get('order_id'),
'poynt_transaction_id': poynt_data.get('transaction_id'),
'poynt_status': status,
'funding_source': poynt_data.get('funding_source', {}),
}
if status in ('DECLINED', 'FAILED', 'REFUND_FAILED'):
self._set_error(
_("Payment was %(status)s by the processor.",
status=status.lower())
)
return
self._process('poynt', payment_data)
def _poynt_create_order(self):
"""Create a Poynt order without a transaction.
Used by the portal payment flow where card details are collected
on the frontend and the transaction is created separately via
the /payment/poynt/process_card route.
:return: Dict with order_id.
:rtype: dict
"""
try:
provider = self._get_provider_sudo()
order_payload = poynt_utils.build_order_payload(
self.reference, self.amount, self.currency_id,
business_id=provider.poynt_business_id,
store_id=provider.poynt_store_id or '',
)
order_result = provider._poynt_make_request(
'POST', 'orders', payload=order_payload,
)
order_id = order_result.get('id', '')
self.poynt_order_id = order_id
return {'order_id': order_id}
except ValidationError as e:
self._set_error(str(e))
return {}
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:
provider = self._get_provider_sudo()
order_payload = poynt_utils.build_order_payload(
self.reference, self.amount, self.currency_id,
business_id=provider.poynt_business_id,
store_id=provider.poynt_store_id or '',
)
order_result = provider._poynt_make_request(
'POST', 'orders', payload=order_payload,
)
order_id = order_result.get('id', '')
self.poynt_order_id = order_id
action = 'AUTHORIZE' if provider.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,
business_id=provider.poynt_business_id,
store_id=provider.poynt_store_id or '',
)
txn_result = provider._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 {}
@staticmethod
def _detect_card_brand_from_details(payment_details):
"""Detect card brand from the payment_details string on a token.
Tokens store details like "VISA ending in 1234" or
"AMERICAN_EXPRESS ending in 5678".
"""
details = (payment_details or '').upper()
if 'AMEX' in details or 'AMERICAN_EXPRESS' in details:
return 'amex'
if 'VISA' in details:
return 'visa'
if 'MASTER' in details:
return 'mastercard'
return 'other'
def _apply_token_surcharge(self):
"""Apply surcharge to the linked invoice for token-based payments.
Checks if surcharge is enabled, detects card brand from the token,
adds a surcharge line to the invoice, and updates the transaction
amount. Skips rental orders (recurring charges should not get
surcharge), invoices with no linked records, or invoices where
surcharge is already applied.
"""
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_poynt.surcharge_enabled', 'False') != 'True':
return
if not self.token_id or not self.invoice_ids:
return
for inv in self.invoice_ids:
sale_orders = inv.mapped('line_ids.sale_line_ids.order_id')
for so in sale_orders:
if getattr(so, 'is_rental_order', False):
if not getattr(so, 'rental_apply_cc_fee', True):
return
card_type = self._detect_card_brand_from_details(
self.token_id.payment_details,
)
rate_key = {
'visa': 'fusion_poynt.surcharge_visa_rate',
'mastercard': 'fusion_poynt.surcharge_mastercard_rate',
'amex': 'fusion_poynt.surcharge_amex_rate',
'debit': 'fusion_poynt.surcharge_debit_rate',
}.get(card_type, 'fusion_poynt.surcharge_other_rate')
rate = float(ICP.get_param(rate_key, '0') or 0)
if rate <= 0:
return
product_id = int(ICP.get_param('fusion_poynt.surcharge_product_id', '0') or 0)
product = self.env['product.product'].sudo().browse(product_id).exists()
if not product:
product = self.env.ref(
'fusion_poynt.product_cc_processing_fee', raise_if_not_found=False,
)
if not product:
_logger.warning("Surcharge product not configured; skipping token surcharge")
return
total_fee = 0.0
for invoice in self.invoice_ids.sudo():
already_has = invoice.invoice_line_ids.filtered(
lambda l: l.product_id.id == product.id
)
if already_has:
continue
fee_amount = round(invoice.amount_residual * rate / 100.0, 2)
if fee_amount <= 0:
continue
was_posted = invoice.state == 'posted'
if was_posted:
invoice.button_draft()
description = "Credit Card Processing Fee (%.2f%% surcharge)" % rate
invoice.write({
'invoice_line_ids': [(0, 0, {
'product_id': product.id,
'name': description,
'quantity': 1,
'price_unit': fee_amount,
'tax_ids': [(5, 0, 0)],
})],
})
if was_posted:
invoice.action_post()
total_fee += fee_amount
if total_fee > 0:
self.amount += total_fee
def _poynt_process_token_payment(self):
"""Process a payment using a stored token (card on file).
Uses the JWT payment token via POST /cards/tokenize/charge when
available. Falls back to the legacy cardId flow for tokens that
were created before the JWT migration.
"""
try:
self._apply_token_surcharge()
provider = self._get_provider_sudo()
action = 'AUTHORIZE' if provider.capture_manually else 'SALE'
payment_jwt = self.token_id.poynt_payment_token
if payment_jwt:
txn_result = provider._poynt_charge_token(
payment_jwt=payment_jwt,
amount=self.amount,
currency=self.currency_id,
action=action,
reference=self.reference,
)
else:
funding_source = {
'type': 'CREDIT_DEBIT',
'card': {
'cardId': self.token_id.poynt_card_id,
},
'entryDetails': {
'customerPresenceStatus': 'MOTO',
'entryMode': 'KEYED',
},
}
order_payload = poynt_utils.build_order_payload(
self.reference, self.amount, self.currency_id,
business_id=provider.poynt_business_id,
store_id=provider.poynt_store_id or '',
)
order_result = provider._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,
business_id=provider.poynt_business_id,
store_id=provider.poynt_store_id or '',
)
txn_result = provider._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
order_id = txn_result.get('orderIdFromTransaction', '') or \
txn_result.get('orderId', '') or \
getattr(self, 'poynt_order_id', '') or ''
if order_id:
self.poynt_order_id = order_id
status = txn_result.get('status', '')
payment_data = {
'reference': self.reference,
'poynt_order_id': order_id,
'poynt_transaction_id': transaction_id,
'poynt_status': status,
'funding_source': txn_result.get('fundingSource', {}),
}
if status in ('DECLINED', 'FAILED', 'REFUND_FAILED'):
processor = txn_result.get('processorResponse', {})
decline_msg = (
processor.get('statusMessage')
or processor.get('message')
or status.lower()
)
self._set_error(
_("Payment %(status)s: %(reason)s",
status=status.lower(), reason=decline_msg)
)
return
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
provider = self._get_provider_sudo()
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, 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',
'fundingSourceType': 'CREDIT_DEBIT',
'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 = provider._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',
'fundingSourceType': 'CREDIT_DEBIT',
'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._get_provider_sudo()._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._get_provider_sudo()._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()
provider = self._get_provider_sudo()
reference = f'{self.reference} - {self.provider_reference or ""}'
payment_method_line = provider.journal_id.inbound_payment_method_line_ids\
.filtered(lambda l: l.payment_provider_id == provider)
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': provider.journal_id.id,
'company_id': provider.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 = provider.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()
self._poynt_auto_send_invoice_and_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._get_provider_sudo()._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.
Idempotent: if a PDF for this transaction is already attached,
skip. Poynt fires multiple webhooks per payment; without this
guard the same invoice would get N duplicate receipt PDFs."""
invoice = self.invoice_ids[:1]
if not invoice:
return
filename = f"Payment_Receipt_{self.reference}.pdf"
existing = self.env['ir.attachment'].sudo().search([
('res_model', '=', 'account.move'),
('res_id', '=', invoice.id),
('name', '=', filename),
], limit=1)
if existing:
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
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.
Idempotent: if a Poynt HTML receipt for this transaction is already
attached to the invoice, we skip the remote call. Poynt sends
several state-change webhooks per transaction (AUTHORIZED, CAPTURED,
UPDATED...) and each one lands here; without this guard every
webhook would re-fetch the receipt and hammer Poynt.
"""
invoice = self.invoice_ids[:1]
if not invoice:
return
filename = f"Poynt_Receipt_{self.reference}.html"
existing = self.env['ir.attachment'].sudo().search([
('res_model', '=', 'account.move'),
('res_id', '=', invoice.id),
('name', '=', filename),
], limit=1)
if existing:
return
receipt_content = self._get_provider_sudo()._poynt_fetch_receipt(
self.poynt_transaction_id,
)
if not receipt_content:
return
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 _poynt_auto_send_invoice_and_receipt(self):
"""Automatically email the invoice and payment receipt to the customer
after a successful payment.
1. Sends the invoice via the standard Odoo invoice email template.
2. Sends the Poynt payment receipt email with the PDF attached.
Idempotent: Poynt fires several state-change webhooks per payment
(AUTHORIZED, CAPTURED, UPDATED...). We use `invoice.is_move_sent`
as the "we've already done this" flag so duplicate webhooks don't
re-email the customer.
Best-effort: failures are logged but never block the payment flow.
"""
self.ensure_one()
invoice = self.invoice_ids[:1]
partner = self.partner_id
if not partner.email:
_logger.info(
"Skipping auto-send for %s: partner %s has no email.",
self.reference, partner.display_name,
)
return
if invoice and invoice.is_move_sent:
_logger.debug(
"Skipping auto-send for %s: invoice %s already sent.",
self.reference, invoice.name,
)
return
# 1. Send the invoice PDF
if invoice and invoice.state == 'posted':
try:
inv_template = self.env.ref(
'account.email_template_edi_invoice',
raise_if_not_found=False,
)
if inv_template:
inv_template.sudo().send_mail(
invoice.id, force_send=True,
)
invoice.sudo().write({'is_move_sent': True})
_logger.info(
"Auto-sent invoice %s to %s",
invoice.name, partner.email,
)
except Exception:
_logger.exception(
"Failed to auto-send invoice %s", invoice.name,
)
# 2. Send the payment receipt
try:
receipt_template = self.env.ref(
'fusion_poynt.mail_template_poynt_receipt',
raise_if_not_found=False,
)
if receipt_template:
receipt_template.sudo().send_mail(self.id, force_send=True)
_logger.info(
"Auto-sent payment receipt for %s to %s",
self.reference, partner.email,
)
except Exception:
_logger.exception(
"Failed to auto-send receipt for %s", self.reference,
)
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 {}