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,6 +1,8 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_move
from . import payment_provider
from . import payment_token
from . import payment_transaction
from . import poynt_terminal
from . import sale_order

View File

@@ -0,0 +1,203 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class AccountMove(models.Model):
_inherit = 'account.move'
poynt_refunded = fields.Boolean(
string="Refunded via Poynt",
readonly=True,
copy=False,
default=False,
)
poynt_refund_count = fields.Integer(
string="Poynt Refund Count",
compute='_compute_poynt_refund_count',
)
has_poynt_receipt = fields.Boolean(
string="Has Poynt Receipt",
compute='_compute_has_poynt_receipt',
)
@api.depends('reversal_move_ids')
def _compute_poynt_refund_count(self):
for move in self:
if move.move_type == 'out_invoice':
move.poynt_refund_count = len(move.reversal_move_ids.filtered(
lambda r: r.poynt_refunded
))
else:
move.poynt_refund_count = 0
def _compute_has_poynt_receipt(self):
for move in self:
move.has_poynt_receipt = bool(move._get_poynt_transaction_for_receipt())
def action_view_poynt_refunds(self):
"""Open the credit notes linked to this invoice that were refunded via Poynt."""
self.ensure_one()
refund_moves = self.reversal_move_ids.filtered(lambda r: r.poynt_refunded)
action = {
'name': _("Poynt Refunds"),
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('id', 'in', refund_moves.ids)],
'context': {'default_move_type': 'out_refund'},
}
if len(refund_moves) == 1:
action['view_mode'] = 'form'
action['res_id'] = refund_moves.id
return action
def _get_poynt_transaction_for_receipt(self):
"""Find the Poynt transaction linked to this invoice or credit note."""
self.ensure_one()
domain = [
('provider_id.code', '=', 'poynt'),
('poynt_transaction_id', '!=', False),
('state', '=', 'done'),
]
if self.move_type == 'out_invoice':
domain.append(('invoice_ids', 'in', self.ids))
elif self.move_type == 'out_refund':
domain += [
('operation', '=', 'refund'),
('invoice_ids', 'in', self.ids),
]
else:
return self.env['payment.transaction']
return self.env['payment.transaction'].sudo().search(
domain, order='id desc', limit=1,
)
def action_resend_poynt_receipt(self):
"""Resend the Poynt payment/refund receipt email to the customer."""
self.ensure_one()
tx = self._get_poynt_transaction_for_receipt()
if not tx:
raise UserError(_(
"No completed Poynt transaction found for this document."
))
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."))
report = self.env.ref(
'fusion_poynt.action_report_poynt_receipt',
raise_if_not_found=False,
)
attachment_ids = []
if report:
pdf_content, _content_type = report.sudo()._render_qweb_pdf(
report_ref='fusion_poynt.action_report_poynt_receipt',
res_ids=tx.ids,
)
prefix = "Refund_Receipt" if self.move_type == 'out_refund' else "Payment_Receipt"
filename = f"{prefix}_{tx.reference}.pdf"
att = self.env['ir.attachment'].create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': self._name,
'res_id': self.id,
'mimetype': 'application/pdf',
})
attachment_ids = [att.id]
template.send_mail(tx.id, force_send=True)
is_refund = self.move_type == 'out_refund'
label = _("Refund") if is_refund else _("Payment")
self.message_post(
body=_(
"%(label)s receipt resent to %(email)s.",
label=label,
email=tx.partner_id.email,
),
message_type='notification',
subtype_xmlid='mail.mt_note',
attachment_ids=attachment_ids,
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _("Receipt Sent"),
'message': _("The receipt has been sent to %s.",
tx.partner_id.email),
'type': 'success',
'sticky': False,
},
}
def action_open_poynt_payment_wizard(self):
"""Open the Poynt payment collection wizard for this invoice."""
self.ensure_one()
return {
'name': _("Collect Poynt Payment"),
'type': 'ir.actions.act_window',
'res_model': 'poynt.payment.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_model': 'account.move',
'active_id': self.id,
},
}
def action_open_poynt_refund_wizard(self):
"""Open the Poynt refund wizard for this credit note."""
self.ensure_one()
return {
'name': _("Refund via Poynt"),
'type': 'ir.actions.act_window',
'res_model': 'poynt.refund.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_model': 'account.move',
'active_id': self.id,
},
}
def _get_original_poynt_transaction(self):
"""Find the Poynt payment transaction from the reversed invoice.
For credit notes created via the "Reverse" action, the
``reversed_entry_id`` links back to the original invoice.
We look for a confirmed Poynt transaction on that invoice.
"""
self.ensure_one()
origin_invoice = self.reversed_entry_id
if not origin_invoice:
return self.env['payment.transaction']
tx = self.env['payment.transaction'].sudo().search([
('invoice_ids', 'in', origin_invoice.ids),
('state', '=', 'done'),
('provider_id.code', '=', 'poynt'),
('poynt_transaction_id', '!=', False),
('poynt_voided', '=', False),
], order='id desc', limit=1)
if not tx:
tx = self.env['payment.transaction'].sudo().search([
('invoice_ids', 'in', origin_invoice.ids),
('state', 'in', ('done', 'cancel')),
('provider_id.code', '=', 'poynt'),
('poynt_transaction_id', '!=', False),
], order='id desc', limit=1)
return tx

View File

@@ -53,6 +53,13 @@ class PaymentProvider(models.Model):
copy=False,
groups='base.group_system',
)
poynt_default_terminal_id = fields.Many2one(
'poynt.terminal',
string="Default Terminal",
help="The default Poynt terminal used for in-store payment collection. "
"Staff can override this per transaction.",
domain="[('provider_id', '=', id), ('active', '=', True)]",
)
# Cached access token fields (not visible in UI)
_poynt_access_token = fields.Char(
@@ -121,10 +128,16 @@ class PaymentProvider(models.Model):
},
headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Api-Version': const.API_VERSION,
'api-version': const.API_VERSION,
'Accept': 'application/json',
},
timeout=30,
)
if response.status_code >= 400:
_logger.error(
"Poynt token request failed (HTTP %s): %s",
response.status_code, response.text[:1000],
)
response.raise_for_status()
token_data = response.json()
except requests.exceptions.RequestException as e:
@@ -210,24 +223,32 @@ class PaymentProvider(models.Model):
_("Poynt authentication expired. Please retry.")
)
if response.status_code == 204:
if response.status_code in (202, 204):
return {}
try:
result = response.json()
except ValueError:
if response.status_code < 400:
return {}
_logger.error("Poynt returned non-JSON response: %s", response.text[:500])
raise ValidationError(_("Poynt returned an invalid response."))
if response.status_code >= 400:
error_msg = result.get('message', result.get('developerMessage', 'Unknown error'))
dev_msg = result.get('developerMessage', '')
_logger.error(
"Poynt API error %s: %s (request_id=%s)",
"Poynt API error %s: %s (request_id=%s)\n"
" URL: %s %s\n Payload: %s\n Response: %s\n Developer: %s",
response.status_code, error_msg, request_id,
method, url,
json.dumps(payload)[:2000] if payload else 'None',
response.text[:2000],
dev_msg,
)
raise ValidationError(
_("Poynt API error (%(code)s): %(msg)s",
code=response.status_code, msg=error_msg)
code=response.status_code, msg=dev_msg or error_msg)
)
return result
@@ -252,6 +273,7 @@ class PaymentProvider(models.Model):
minor_amount = poynt_utils.format_poynt_amount(amount, currency) if amount else 0
inline_form_values = {
'provider_id': self.id,
'business_id': self.poynt_business_id,
'application_id': self.poynt_application_id,
'currency_name': currency.name if currency else 'USD',
@@ -283,7 +305,11 @@ class PaymentProvider(models.Model):
# === ACTION METHODS === #
def action_poynt_test_connection(self):
"""Test the connection to Poynt by fetching business info.
"""Test the connection to Poynt by authenticating and fetching business info.
If the Business ID appears to be a numeric MID rather than a UUID,
the method attempts to decode the access token to find the real
business UUID and auto-correct it.
:return: A notification action with the result.
:rtype: dict
@@ -291,11 +317,25 @@ class PaymentProvider(models.Model):
self.ensure_one()
try:
access_token = self._poynt_get_access_token()
business_id = self.poynt_business_id
is_uuid = business_id and '-' in business_id and len(business_id) > 30
if not is_uuid and business_id:
resolved_biz_id = self._poynt_resolve_business_id(access_token)
if resolved_biz_id:
self.sudo().write({'poynt_business_id': resolved_biz_id})
_logger.info(
"Auto-corrected Business ID from MID %s to UUID %s",
business_id, resolved_biz_id,
)
result = self._poynt_make_request('GET', '')
business_name = result.get('legalName', result.get('doingBusinessAs', 'Unknown'))
message = _(
"Connection successful. Business: %(name)s",
"Connection successful. Business: %(name)s (ID: %(bid)s)",
name=business_name,
bid=self.poynt_business_id,
)
notification_type = 'success'
except (ValidationError, UserError) as e:
@@ -312,30 +352,71 @@ class PaymentProvider(models.Model):
},
}
def _poynt_resolve_business_id(self, access_token):
"""Try to extract the real business UUID from the access token JWT.
The Poynt access token contains a 'poynt.biz' claim with the
merchant's business UUID when the token was obtained via merchant
authorization. For app-level tokens, we fall back to the 'poynt.org'
claim or attempt a direct API lookup.
:param str access_token: The current access token.
:return: The business UUID, or False if it cannot be resolved.
:rtype: str or bool
"""
try:
import jwt as pyjwt
claims = pyjwt.decode(access_token, options={"verify_signature": False})
biz_id = claims.get('poynt.biz') or claims.get('poynt.org')
if biz_id:
return biz_id
except Exception as e:
_logger.warning("Could not decode access token to find business ID: %s", e)
return False
def action_poynt_fetch_terminals(self):
"""Fetch terminal devices from Poynt and create/update local records.
Uses GET /businesses/{id}/stores which returns stores with their
nested storeDevices arrays. The main business endpoint does not
include stores in its response.
:return: A notification action with the result.
:rtype: dict
"""
self.ensure_one()
try:
store_id = self.poynt_store_id
if store_id:
endpoint = f'stores/{store_id}/storeDevices'
else:
endpoint = 'storeDevices'
result = self._poynt_make_request('GET', 'stores')
stores = result if isinstance(result, list) else result.get('stores', [])
result = self._poynt_make_request('GET', endpoint)
devices = result if isinstance(result, list) else result.get('storeDevices', [])
all_devices = []
for store in stores:
store_id = store.get('id', '')
for device in store.get('storeDevices', []):
device['_store_id'] = store_id
device['_store_name'] = store.get('displayName', store.get('name', ''))
all_devices.append(device)
if not all_devices:
return self._poynt_notification(
_("No terminal devices found for this business."), 'warning'
)
terminal_model = self.env['poynt.terminal']
created = 0
updated = 0
for device in devices:
first_store_id = None
for device in all_devices:
device_id = device.get('deviceId', '')
if not device_id:
continue
store_id = device.get('_store_id', '')
if not first_store_id and store_id:
first_store_id = store_id
existing = terminal_model.search([
('device_id', '=', device_id),
('provider_id', '=', self.id),
@@ -347,7 +428,7 @@ class PaymentProvider(models.Model):
'serial_number': device.get('serialNumber', ''),
'provider_id': self.id,
'status': 'online' if device.get('status') == 'ACTIVATED' else 'offline',
'store_id_poynt': device.get('storeId', ''),
'store_id_poynt': store_id,
}
if existing:
@@ -357,15 +438,53 @@ class PaymentProvider(models.Model):
terminal_model.create(vals)
created += 1
if first_store_id and not self.poynt_store_id:
self.sudo().write({'poynt_store_id': first_store_id})
_logger.info("Auto-filled Store ID: %s", first_store_id)
message = _(
"Terminals synced: %(created)s created, %(updated)s updated.",
created=created, updated=updated,
)
notification_type = 'success'
return self._poynt_notification(message, 'success')
except (ValidationError, UserError) as e:
message = _("Failed to fetch terminals: %(error)s", error=str(e))
notification_type = 'danger'
return self._poynt_notification(
_("Failed to fetch terminals: %(error)s", error=str(e)), 'danger'
)
def _poynt_fetch_receipt(self, transaction_id):
"""Fetch the rendered receipt from Poynt for a given transaction.
Calls GET /businesses/{businessId}/transactions/{transactionId}/receipt
which returns a TransactionReceipt with a ``data`` field containing
the rendered receipt content (HTML or text).
:param str transaction_id: The Poynt transaction UUID.
:return: The receipt content string, or None on failure.
:rtype: str | None
"""
self.ensure_one()
if not transaction_id:
return None
try:
result = self._poynt_make_request(
'GET', f'transactions/{transaction_id}/receipt',
)
return result.get('data') or None
except (ValidationError, Exception):
_logger.debug(
"Could not fetch Poynt receipt for transaction %s", transaction_id,
)
return None
def _poynt_notification(self, message, notification_type='info'):
"""Return a display_notification action.
:param str message: The notification message.
:param str notification_type: One of 'success', 'warning', 'danger', 'info'.
:return: The notification action dict.
:rtype: dict
"""
return {
'type': 'ir.actions.client',
'tag': 'display_notification',

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

View File

@@ -1,5 +1,6 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
from odoo import _, api, fields, models
@@ -129,22 +130,29 @@ class PoyntTerminal(models.Model):
if order_id:
payment_request['orderId'] = order_id
store_id = self.store_id_poynt or self.provider_id.poynt_store_id or ''
data_str = json.dumps({
'action': 'sale',
'purchaseAmount': minor_amount,
'tipAmount': 0,
'currency': currency.name,
'referenceId': reference,
'callbackUrl': self._get_terminal_callback_url(),
})
try:
result = self.provider_id._poynt_make_request(
'POST',
f'cloudMessages',
'cloudMessages',
business_scoped=False,
payload={
'businessId': self.provider_id.poynt_business_id,
'storeId': store_id,
'deviceId': self.device_id,
'ttl': 300,
'serialNum': self.serial_number or '',
'data': {
'action': 'sale',
'purchaseAmount': minor_amount,
'tipAmount': 0,
'currency': currency.name,
'referenceId': reference,
'callbackUrl': self._get_terminal_callback_url(),
},
'data': data_str,
},
)
_logger.info(
@@ -171,6 +179,9 @@ class PoyntTerminal(models.Model):
def action_check_terminal_payment_status(self, reference):
"""Poll for the status of a terminal payment.
Searches Poynt transactions by referenceId (set via cloud message)
and falls back to notes field.
:param str reference: The Odoo transaction reference.
:return: Dict with status and transaction data if completed.
:rtype: dict
@@ -182,15 +193,37 @@ class PoyntTerminal(models.Model):
'GET',
'transactions',
params={
'notes': reference,
'limit': 1,
'referenceId': reference,
'limit': 5,
},
)
transactions = txn_result.get('transactions', [])
if not transactions:
txn_result = self.provider_id._poynt_make_request(
'GET',
'transactions',
params={
'notes': reference,
'limit': 5,
},
)
transactions = txn_result.get('transactions', [])
if not transactions:
return {'status': 'pending', 'message': 'Waiting for terminal response...'}
for txn in transactions:
status = txn.get('status', 'UNKNOWN')
if status in ('CAPTURED', 'AUTHORIZED', 'SETTLED'):
return {
'status': status,
'transaction_id': txn.get('id', ''),
'funding_source': txn.get('fundingSource', {}),
'amounts': txn.get('amounts', {}),
}
txn = transactions[0]
return {
'status': txn.get('status', 'UNKNOWN'),

View File

@@ -0,0 +1,60 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import _, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_poynt_collect_payment(self):
"""Create an invoice (if needed) and open the Poynt payment wizard.
This shortcut lets staff collect payment directly from a confirmed
sale order without manually creating and posting the invoice first.
"""
self.ensure_one()
if self.state not in ('sale', 'done'):
raise UserError(
_("You can only collect payment on confirmed orders.")
)
invoice = self.invoice_ids.filtered(
lambda inv: inv.state == 'posted'
and inv.payment_state in ('not_paid', 'partial')
and inv.move_type == 'out_invoice'
)[:1]
if not invoice:
draft_invoices = self.invoice_ids.filtered(
lambda inv: inv.state == 'draft'
and inv.move_type == 'out_invoice'
)
if draft_invoices:
invoice = draft_invoices[0]
invoice.action_post()
else:
invoices = self._create_invoices()
if not invoices:
raise UserError(
_("Could not create an invoice for this order.")
)
invoice = invoices[0]
invoice.action_post()
return {
'name': _("Collect Poynt Payment"),
'type': 'ir.actions.act_window',
'res_model': 'poynt.payment.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_model': 'account.move',
'active_id': invoice.id,
},
}