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

@@ -2,6 +2,7 @@
from . import controllers
from . import models
from . import wizard
def post_init_hook(env):

View File

@@ -7,15 +7,24 @@
'sequence': 360,
'summary': "GoDaddy Poynt payment processing for cloud and terminal payments.",
'description': " ",
'depends': ['payment', 'account_payment'],
'depends': ['payment', 'account_payment', 'sale'],
'data': [
'security/ir.model.access.csv',
'report/poynt_receipt_report.xml',
'report/poynt_receipt_templates.xml',
'views/payment_provider_views.xml',
'views/payment_transaction_views.xml',
'views/payment_poynt_templates.xml',
'views/poynt_terminal_views.xml',
'views/account_move_views.xml',
'views/sale_order_views.xml',
'wizard/poynt_payment_wizard_views.xml',
'wizard/poynt_refund_wizard_views.xml',
'data/payment_provider_data.xml',
'data/poynt_receipt_email_template.xml',
],
'post_init_hook': 'post_init_hook',
'uninstall_hook': 'uninstall_hook',
@@ -23,6 +32,10 @@
'web.assets_frontend': [
'fusion_poynt/static/src/interactions/**/*',
],
'web.assets_backend': [
'fusion_poynt/static/src/js/**/*',
'fusion_poynt/static/src/xml/**/*',
],
},
'author': 'Fusion Apps',
'license': 'LGPL-3',

View File

@@ -7,6 +7,7 @@
<field name="inline_form_view_id" ref="inline_form"/>
<field name="allow_tokenization">True</field>
<field name="state">disabled</field>
<field name="image_128" type="base64" file="fusion_poynt/static/src/img/poynt_logo.png"/>
</record>
</odoo>

View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="mail_template_poynt_receipt" model="mail.template">
<field name="name">Poynt: Payment/Refund Receipt</field>
<field name="model_id" ref="payment.model_payment_transaction"/>
<field name="subject"><![CDATA[{{ object.company_id.name }} - <t t-if="object.operation == 'refund' or object.amount < 0">Refund Receipt</t><t t-else="">Payment Receipt</t> {{ object.reference or 'n/a' }}]]></field>
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="report_template_ids"
eval="[(4, ref('fusion_poynt.action_report_poynt_receipt'))]"/>
<field name="body_html"><![CDATA[
<t t-set="is_refund" t-value="object.operation == 'refund' or object.amount &lt; 0"/>
<t t-set="accent" t-value="'#dc3545' if is_refund else '#28a745'"/>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div t-attf-style="height:4px;background-color:{{ accent }};"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<p t-attf-style="color:{{ accent }};font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">
<t t-if="is_refund">Refund Receipt</t>
<t t-else="">Payment Receipt</t>
</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
<t t-if="is_refund">
Your refund for <strong style="color:#2d3748;"><t t-out="object.reference"/></strong> has been processed.
</t>
<t t-else="">
Your payment for <strong style="color:#2d3748;"><t t-out="object.reference"/></strong> has been processed successfully.
</t>
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Transaction Details</td></tr>
<tr>
<td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Type</td>
<td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #f0f0f0;">
<t t-if="is_refund"><strong style="color:#dc3545;">Refund</strong></t>
<t t-else=""><strong style="color:#28a745;">Payment</strong></t>
</td>
</tr>
<tr>
<td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Reference</td>
<td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.reference"/></td>
</tr>
<tr>
<td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td>
<td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.create_date.strftime('%B %d, %Y')"/></td>
</tr>
<tr>
<td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Status</td>
<td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #f0f0f0;">
<t t-if="is_refund"><strong style="color:#dc3545;">Refunded</strong></t>
<t t-else=""><strong style="color:#28a745;">Confirmed</strong></t>
</td>
</tr>
<tr>
<td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Amount</td>
<td t-attf-style="padding:10px 14px;color:{{ accent }};font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;">
<t t-if="is_refund">- </t><t t-out="object.currency_id.symbol"/><t t-out="'%.2f' % abs(object.amount)"/> <t t-out="object.currency_id.name"/>
</td>
</tr>
</table>
<div style="padding:10px 14px;border:1px dashed #e2e8f0;border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;color:#718096;"><strong style="color:#2d3748;">Attached:</strong> Transaction Receipt (PDF)</p>
</div>
<div t-attf-style="border-left:3px solid {{ accent }};padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">
<t t-if="is_refund">
The refund will appear on your card within 3-5 business days. If you have any questions, please do not hesitate to contact us.
</t>
<t t-else="">
Thank you for your payment. If you have any questions about this transaction, please do not hesitate to contact us.
</t>
</p>
</div>
<t t-if="object.company_id.phone or object.company_id.email">
<p style="margin:0 0 4px 0;font-size:13px;color:#718096;">
<t t-if="object.company_id.phone"><t t-out="object.company_id.phone"/></t>
<t t-if="object.company_id.phone and object.company_id.email"> | </t>
<t t-if="object.company_id.email"><t t-out="object.company_id.email"/></t>
</p>
</t>
</div>
</div>
]]></field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
</data>
</odoo>

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

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_report_poynt_receipt" model="ir.actions.report">
<field name="name">Poynt Payment Receipt</field>
<field name="model">payment.transaction</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_poynt.report_poynt_receipt_document</field>
<field name="report_file">fusion_poynt.report_poynt_receipt_document</field>
<field name="print_report_name">'Payment_Receipt_%s' % object.reference</field>
<field name="binding_type">report</field>
</record>
</odoo>

View File

@@ -0,0 +1,303 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_poynt_receipt_document">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="tx">
<t t-set="receipt" t-value="tx._get_poynt_receipt_values()"/>
<t t-set="company" t-value="tx.company_id or tx.env.company"/>
<t t-set="is_refund" t-value="tx.operation == 'refund' or tx.amount &lt; 0"/>
<t t-call="web.external_layout">
<div class="page" style="font-family: 'Courier New', Courier, monospace;">
<div class="text-center mb-3">
<h4>
<strong t-if="is_refund">REFUND RECEIPT</strong>
<strong t-else="">PAYMENT RECEIPT</strong>
</h4>
</div>
<!-- Transaction details table -->
<table class="table table-sm table-borderless" style="font-size: 13px;">
<tbody>
<tr>
<td class="text-muted" style="width: 40%;">Date</td>
<td>
<t t-if="receipt.get('created_at')">
<t t-esc="receipt['created_at']"/>
</t>
<t t-else="">
<span t-field="tx.create_date" t-options="{'widget': 'datetime'}"/>
</t>
</td>
</tr>
<tr>
<td class="text-muted">Reference</td>
<td><strong t-field="tx.reference"/></td>
</tr>
<tr t-if="receipt.get('transaction_id')">
<td class="text-muted">Transaction ID</td>
<td style="font-size: 11px;"><t t-esc="receipt['transaction_id']"/></td>
</tr>
<tr t-if="is_refund and tx.source_transaction_id">
<td class="text-muted">Original Transaction</td>
<td style="font-size: 11px;"><t t-esc="tx.source_transaction_id.reference"/></td>
</tr>
<tr>
<td class="text-muted">Type</td>
<td>
<strong t-if="is_refund" style="color: #dc3545;">REFUND</strong>
<strong t-else="" style="color: #28a745;">SALE</strong>
</td>
</tr>
</tbody>
</table>
<hr style="border-top: 1px solid #999;"/>
<!-- Card info -->
<table class="table table-sm table-borderless" style="font-size: 13px;">
<tbody>
<tr t-if="receipt.get('card_type')">
<td class="text-muted" style="width: 40%;">Card Type</td>
<td><t t-esc="receipt['card_type']"/></td>
</tr>
<tr t-if="receipt.get('card_last4')">
<td class="text-muted">Card Number</td>
<td>**** **** **** <t t-esc="receipt['card_last4']"/></td>
</tr>
<tr t-if="receipt.get('card_holder')">
<td class="text-muted">Cardholder</td>
<td><t t-esc="receipt['card_holder']"/></td>
</tr>
<tr t-if="receipt.get('entry_mode')">
<td class="text-muted">Entry Mode</td>
<td><t t-esc="receipt['entry_mode']"/></td>
</tr>
</tbody>
</table>
<hr style="border-top: 1px solid #999;"/>
<!-- Amounts -->
<table class="table table-sm table-borderless" style="font-size: 14px;">
<tbody>
<tr t-if="receipt.get('tip_amount') and not is_refund">
<td class="text-muted" style="width: 40%;">Subtotal</td>
<td class="text-end">
<t t-esc="receipt.get('currency', 'CAD')"/>
<t t-esc="'%.2f' % (receipt.get('transaction_amount', 0) - receipt.get('tip_amount', 0))"/>
</td>
</tr>
<tr t-if="receipt.get('tip_amount') and not is_refund">
<td class="text-muted">Tip</td>
<td class="text-end">
<t t-esc="receipt.get('currency', 'CAD')"/>
<t t-esc="'%.2f' % receipt['tip_amount']"/>
</td>
</tr>
<tr>
<td>
<strong t-if="is_refund">REFUND TOTAL</strong>
<strong t-else="">TOTAL</strong>
</td>
<td class="text-end">
<strong t-attf-style="color: {{ 'dc3545' if is_refund else '000' }};">
<t t-if="is_refund">- </t>
<t t-esc="receipt.get('currency', 'CAD')"/>
<t t-esc="'%.2f' % abs(receipt.get('transaction_amount', 0) or abs(tx.amount))"/>
</strong>
</td>
</tr>
</tbody>
</table>
<hr style="border-top: 1px solid #999;"/>
<!-- Approval -->
<table class="table table-sm table-borderless" style="font-size: 13px;">
<tbody>
<tr t-if="receipt.get('approval_code')">
<td class="text-muted" style="width: 40%;">Approval Code</td>
<td><strong><t t-esc="receipt['approval_code']"/></strong></td>
</tr>
<tr t-if="receipt.get('status')">
<td class="text-muted">Status</td>
<td><t t-esc="receipt['status']"/></td>
</tr>
<tr t-if="receipt.get('processor')">
<td class="text-muted">Processor</td>
<td><t t-esc="receipt['processor']"/></td>
</tr>
</tbody>
</table>
<hr style="border-top: 2px dashed #333;"/>
<!-- Footer -->
<div class="text-center mt-3" style="font-size: 12px;">
<p class="mb-1">
<t t-if="is_refund">Credit Note: </t>
<t t-else="">Invoice: </t>
<strong t-esc="', '.join(tx.invoice_ids.mapped('name'))" />
</p>
<p class="mb-1">
Customer: <strong t-field="tx.partner_id.name"/>
</p>
<p class="text-muted mt-3" t-if="is_refund">
Refund processed. The amount will be credited to your
card within 3-5 business days.
</p>
<p class="text-muted mt-3" t-else="">
Thank you for your payment.
</p>
</div>
</div>
</t>
<!-- Page 2: Original Sale Receipt (only for refunds) -->
<t t-if="is_refund and tx.source_transaction_id">
<t t-set="src_tx" t-value="tx.source_transaction_id"/>
<t t-set="src_receipt" t-value="tx._get_source_receipt_values()"/>
<t t-if="src_receipt">
<t t-call="web.external_layout">
<div class="page" style="font-family: 'Courier New', Courier, monospace;">
<div class="text-center mb-3">
<h4><strong>ORIGINAL SALE RECEIPT</strong></h4>
<p class="text-muted" style="font-size: 12px;">
Reference for refund <strong t-field="tx.reference"/>
</p>
</div>
<!-- Transaction details -->
<table class="table table-sm table-borderless" style="font-size: 13px;">
<tbody>
<tr>
<td class="text-muted" style="width: 40%;">Date</td>
<td>
<t t-if="src_receipt.get('created_at')">
<t t-esc="src_receipt['created_at']"/>
</t>
<t t-else="">
<span t-field="src_tx.create_date" t-options="{'widget': 'datetime'}"/>
</t>
</td>
</tr>
<tr>
<td class="text-muted">Reference</td>
<td><strong t-field="src_tx.reference"/></td>
</tr>
<tr t-if="src_receipt.get('transaction_id')">
<td class="text-muted">Transaction ID</td>
<td style="font-size: 11px;"><t t-esc="src_receipt['transaction_id']"/></td>
</tr>
<tr>
<td class="text-muted">Type</td>
<td><strong style="color: #28a745;">SALE</strong></td>
</tr>
</tbody>
</table>
<hr style="border-top: 1px solid #999;"/>
<!-- Card info -->
<table class="table table-sm table-borderless" style="font-size: 13px;">
<tbody>
<tr t-if="src_receipt.get('card_type')">
<td class="text-muted" style="width: 40%;">Card Type</td>
<td><t t-esc="src_receipt['card_type']"/></td>
</tr>
<tr t-if="src_receipt.get('card_last4')">
<td class="text-muted">Card Number</td>
<td>**** **** **** <t t-esc="src_receipt['card_last4']"/></td>
</tr>
<tr t-if="src_receipt.get('card_holder')">
<td class="text-muted">Cardholder</td>
<td><t t-esc="src_receipt['card_holder']"/></td>
</tr>
<tr t-if="src_receipt.get('entry_mode')">
<td class="text-muted">Entry Mode</td>
<td><t t-esc="src_receipt['entry_mode']"/></td>
</tr>
</tbody>
</table>
<hr style="border-top: 1px solid #999;"/>
<!-- Amounts -->
<table class="table table-sm table-borderless" style="font-size: 14px;">
<tbody>
<tr t-if="src_receipt.get('tip_amount')">
<td class="text-muted" style="width: 40%;">Subtotal</td>
<td class="text-end">
<t t-esc="src_receipt.get('currency', 'CAD')"/>
<t t-esc="'%.2f' % (src_receipt.get('transaction_amount', 0) - src_receipt.get('tip_amount', 0))"/>
</td>
</tr>
<tr t-if="src_receipt.get('tip_amount')">
<td class="text-muted">Tip</td>
<td class="text-end">
<t t-esc="src_receipt.get('currency', 'CAD')"/>
<t t-esc="'%.2f' % src_receipt['tip_amount']"/>
</td>
</tr>
<tr>
<td><strong>TOTAL</strong></td>
<td class="text-end">
<strong>
<t t-esc="src_receipt.get('currency', 'CAD')"/>
<t t-esc="'%.2f' % abs(src_receipt.get('transaction_amount', 0) or abs(src_tx.amount))"/>
</strong>
</td>
</tr>
</tbody>
</table>
<hr style="border-top: 1px solid #999;"/>
<!-- Approval -->
<table class="table table-sm table-borderless" style="font-size: 13px;">
<tbody>
<tr t-if="src_receipt.get('approval_code')">
<td class="text-muted" style="width: 40%;">Approval Code</td>
<td><strong><t t-esc="src_receipt['approval_code']"/></strong></td>
</tr>
<tr t-if="src_receipt.get('status')">
<td class="text-muted">Status</td>
<td><t t-esc="src_receipt['status']"/></td>
</tr>
<tr t-if="src_receipt.get('processor')">
<td class="text-muted">Processor</td>
<td><t t-esc="src_receipt['processor']"/></td>
</tr>
</tbody>
</table>
<hr style="border-top: 2px dashed #333;"/>
<!-- Footer -->
<div class="text-center mt-3" style="font-size: 12px;">
<p class="mb-1">
Invoice: <strong t-esc="', '.join(src_tx.invoice_ids.mapped('name'))"/>
</p>
<p class="mb-1">
Customer: <strong t-field="src_tx.partner_id.name"/>
</p>
<p class="text-muted mt-3">
This is the original sale transaction associated
with the refund on Page 1.
</p>
</div>
</div>
</t>
</t>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -1,3 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_poynt_terminal_user,poynt.terminal.user,model_poynt_terminal,base.group_user,1,0,0,0
access_poynt_terminal_admin,poynt.terminal.admin,model_poynt_terminal,base.group_system,1,1,1,1
access_poynt_payment_wizard_user,poynt.payment.wizard.user,model_poynt_payment_wizard,account.group_account_invoice,1,1,1,0
access_poynt_payment_wizard_admin,poynt.payment.wizard.admin,model_poynt_payment_wizard,base.group_system,1,1,1,1
access_poynt_refund_wizard_user,poynt.refund.wizard.user,model_poynt_refund_wizard,account.group_account_invoice,1,1,1,0
access_poynt_refund_wizard_admin,poynt.refund.wizard.admin,model_poynt_refund_wizard,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_poynt_terminal_user poynt.terminal.user model_poynt_terminal base.group_user 1 0 0 0
3 access_poynt_terminal_admin poynt.terminal.admin model_poynt_terminal base.group_system 1 1 1 1
4 access_poynt_payment_wizard_user poynt.payment.wizard.user model_poynt_payment_wizard account.group_account_invoice 1 1 1 0
5 access_poynt_payment_wizard_admin poynt.payment.wizard.admin model_poynt_payment_wizard base.group_system 1 1 1 1
6 access_poynt_refund_wizard_user poynt.refund.wizard.user model_poynt_refund_wizard account.group_account_invoice 1 1 1 0
7 access_poynt_refund_wizard_admin poynt.refund.wizard.admin model_poynt_refund_wizard base.group_system 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -0,0 +1,69 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Component, onMounted, onWillUnmount, useState } from "@odoo/owl";
class PoyntPollAction extends Component {
static template = "fusion_poynt.PoyntPollAction";
static props = ["*"];
setup() {
this.orm = useService("orm");
this.actionService = useService("action");
this.state = useState({ seconds: 0, message: "" });
const wizardId = this.props.action?.params?.wizard_id;
this.wizardId = wizardId;
onMounted(() => {
if (!this.wizardId) return;
this._tickTimer = setInterval(() => this.state.seconds++, 1000);
this._pollTimer = setInterval(() => this._poll(), 5000);
});
onWillUnmount(() => this._stop());
}
_stop() {
if (this._tickTimer) { clearInterval(this._tickTimer); this._tickTimer = null; }
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
}
async _poll() {
if (!this.wizardId) { this._stop(); return; }
try {
const result = await this.orm.call(
"poynt.payment.wizard", "action_check_status", [[this.wizardId]],
);
if (result && result.type) {
this._stop();
this.actionService.doAction(result, { clearBreadcrumbs: true });
}
} catch (e) {
this._stop();
this.state.message = "Polling stopped due to an error. Use Check Now to retry.";
}
}
async onManualCheck() {
this.state.message = "";
if (!this._pollTimer) {
this._pollTimer = setInterval(() => this._poll(), 5000);
this._tickTimer = setInterval(() => this.state.seconds++, 1000);
}
await this._poll();
}
async onCancel() {
this._stop();
try {
await this.orm.call(
"poynt.payment.wizard", "action_cancel_payment", [[this.wizardId]],
);
} catch { /* ignore */ }
this.actionService.doAction({ type: "ir.actions.act_window_close" });
}
}
registry.category("actions").add("poynt_poll_action", PoyntPollAction);

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="fusion_poynt.PoyntPollAction">
<div class="o_action p-4">
<div class="alert alert-info d-flex align-items-center gap-3 mb-4">
<span class="spinner-border spinner-border-sm" role="status"/>
<div>
<strong>Waiting for terminal response...</strong>
<div class="text-muted small mt-1">
Auto-checking every 5 seconds
(<t t-esc="state.seconds"/>s elapsed)
</div>
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary" t-on-click="onManualCheck">
Check Now
</button>
<button class="btn btn-secondary" t-on-click="onCancel">
Cancel Payment
</button>
</div>
</div>
</t>
</templates>

View File

@@ -41,16 +41,37 @@ def build_api_headers(access_token, request_id=None):
:return: The request headers dict.
:rtype: dict
"""
if not request_id:
request_id = generate_request_id()
headers = {
'Content-Type': 'application/json',
'Api-Version': const.API_VERSION,
'api-version': const.API_VERSION,
'Accept': 'application/json',
'Authorization': f'Bearer {access_token}',
'Poynt-Request-Id': request_id,
}
if request_id:
headers['POYNT-REQUEST-ID'] = request_id
return headers
def clean_application_id(raw_app_id):
"""Extract the urn:aid:... portion from a raw application ID string.
Poynt developer portal sometimes displays the app UUID and URN together
(e.g. 'a73a2957-...=urn:aid:fb0ba879-...'). The JWT needs only the URN.
:param str raw_app_id: The raw application ID string.
:return: The cleaned application ID (urn:aid:...).
:rtype: str
"""
if not raw_app_id:
return raw_app_id
raw_app_id = raw_app_id.strip()
if 'urn:aid:' in raw_app_id:
idx = raw_app_id.index('urn:aid:')
return raw_app_id[idx:]
return raw_app_id
def create_self_signed_jwt(application_id, private_key_pem):
"""Create a self-signed JWT for Poynt OAuth2 token request.
@@ -81,10 +102,12 @@ def create_self_signed_jwt(application_id, private_key_pem):
private_key = load_pem_private_key(key_bytes, password=None, backend=default_backend())
app_id = clean_application_id(application_id)
now = int(time.time())
payload = {
'iss': application_id,
'sub': application_id,
'iss': app_id,
'sub': app_id,
'aud': 'https://services.poynt.net',
'iat': now,
'exp': now + 300,
@@ -162,12 +185,15 @@ def get_poynt_status(status_str):
return 'error'
def build_order_payload(reference, amount, currency, items=None, notes=''):
def build_order_payload(reference, amount, currency, business_id='',
store_id='', items=None, notes=''):
"""Build a Poynt order creation payload.
:param str reference: The Odoo transaction reference.
:param float amount: The order total in major currency units.
:param recordset currency: The currency record.
:param str business_id: The Poynt business UUID.
:param str store_id: The Poynt store UUID.
:param list items: Optional list of order item dicts.
:param str notes: Optional order notes.
:return: The Poynt-formatted order payload.
@@ -185,6 +211,15 @@ def build_order_payload(reference, amount, currency, items=None, notes=''):
'unitOfMeasure': 'EACH',
}]
context = {
'source': 'WEB',
'sourceApp': 'odoo.fusion_poynt',
}
if business_id:
context['businessId'] = business_id
if store_id:
context['storeId'] = store_id
return {
'items': items,
'amounts': {
@@ -195,10 +230,7 @@ def build_order_payload(reference, amount, currency, items=None, notes=''):
'netTotal': minor_amount,
'currency': currency.name,
},
'context': {
'source': 'WEB',
'transactionInstruction': 'ONLINE_AUTH_REQUIRED',
},
'context': context,
'statuses': {
'status': 'OPENED',
},

View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_move_form_poynt_button" model="ir.ui.view">
<field name="name">account.move.form.poynt.button</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="priority">60</field>
<field name="arch" type="xml">
<!-- Poynt Refund smart button on invoices -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_poynt_refunds"
type="object"
class="oe_stat_button"
icon="fa-undo"
invisible="poynt_refund_count == 0">
<field name="poynt_refund_count" widget="statinfo" string="Poynt Refunds"/>
</button>
</xpath>
<!-- Collect payment button on invoices -->
<xpath expr="//button[@id='account_invoice_payment_btn']" position="after">
<button name="action_open_poynt_payment_wizard"
string="Collect Poynt Payment"
type="object"
class="btn-secondary"
icon="fa-credit-card"
invisible="state != 'posted' or payment_state not in ('not_paid', 'partial') or move_type != 'out_invoice'"
groups="account.group_account_invoice"
data-hotkey="p"/>
</xpath>
<!-- Refund via Poynt button on credit notes -->
<xpath expr="//button[@id='account_invoice_payment_btn']" position="after">
<button name="action_open_poynt_refund_wizard"
string="Refund via Poynt"
type="object"
class="btn-secondary"
icon="fa-undo"
invisible="state != 'posted' or payment_state not in ('not_paid', 'partial') or move_type != 'out_refund' or poynt_refunded"
groups="account.group_account_invoice"
data-hotkey="r"/>
</xpath>
<!-- Resend Receipt button on invoices (paid via Poynt) -->
<xpath expr="//button[@id='account_invoice_payment_btn']" position="after">
<button name="action_resend_poynt_receipt"
string="Resend Receipt"
type="object"
class="btn-secondary"
icon="fa-envelope"
invisible="state != 'posted' or move_type != 'out_invoice' or not has_poynt_receipt"
groups="account.group_account_invoice"/>
</xpath>
<!-- Resend Receipt button on credit notes (refunded via Poynt) -->
<xpath expr="//button[@id='account_invoice_payment_btn']" position="after">
<button name="action_resend_poynt_receipt"
string="Resend Refund Receipt"
type="object"
class="btn-secondary"
icon="fa-envelope"
invisible="state != 'posted' or move_type != 'out_refund' or not poynt_refunded"
groups="account.group_account_invoice"/>
</xpath>
<!-- Refunded banner on credit notes -->
<xpath expr="//header" position="before">
<div class="alert alert-info text-center mb-0"
role="status"
invisible="not poynt_refunded">
<strong>Refunded via Poynt</strong> — This credit note has been
refunded to the customer's card through Poynt.
</div>
<field name="poynt_refunded" invisible="1"/>
<field name="has_poynt_receipt" invisible="1"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -24,6 +24,7 @@
<div class="o_row" col="2">
<field name="poynt_webhook_secret" password="True"/>
</div>
<field name="poynt_default_terminal_id"/>
</group>
</group>
<group name="provider_credentials" position="after">

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_transaction_form_inherit_poynt" model="ir.ui.view">
<field name="name">payment.transaction.form.inherit.poynt</field>
<field name="model">payment.transaction</field>
<field name="inherit_id" ref="payment.payment_transaction_form"/>
<field name="arch" type="xml">
<!-- Voided banner at top of form -->
<xpath expr="//header" position="before">
<div class="alert alert-warning text-center mb-0"
role="alert"
invisible="not poynt_voided">
<strong>VOIDED</strong> — This transaction was voided on Poynt
on <field name="poynt_void_date" widget="datetime" readonly="1" class="d-inline"/>.
The payment has been reversed before settlement.
</div>
</xpath>
<!-- Void button in header -->
<xpath expr="//header/button[@name='action_void']" position="after">
<button string="Void Transaction"
name="action_poynt_void"
type="object"
class="btn-secondary"
invisible="state != 'done' or provider_code != 'poynt'"
confirm="Are you sure you want to void this transaction? This reverses the payment before settlement and cannot be undone. Only works same-day before closeout (6 PM)."/>
</xpath>
<!-- Add voided fields to the form sheet -->
<xpath expr="//field[@name='provider_reference']" position="after">
<field name="poynt_voided" invisible="1"/>
<field name="poynt_void_date" invisible="not poynt_voided"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -100,11 +100,11 @@
</field>
</record>
<!-- Menu entry under Payment Providers -->
<!-- Menu entry as sibling of Payment Providers under Online Payments -->
<menuitem id="menu_poynt_terminal"
name="Poynt Terminals"
parent="account_payment.payment_provider_menu"
parent="account.root_payment_menu"
action="action_poynt_terminal"
sequence="30"/>
sequence="15"/>
</odoo>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_order_form_poynt_button" model="ir.ui.view">
<field name="name">sale.order.form.poynt.button</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="priority">60</field>
<field name="arch" type="xml">
<xpath expr="//button[@id='create_invoice']" position="after">
<button name="action_poynt_collect_payment"
string="Collect Payment"
type="object"
class="btn-secondary"
icon="fa-credit-card"
invisible="state not in ('sale', 'done')"
data-hotkey="p"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import poynt_payment_wizard
from . import poynt_refund_wizard

View File

@@ -0,0 +1,552 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.addons.fusion_poynt import utils as poynt_utils
_logger = logging.getLogger(__name__)
class PoyntPaymentWizard(models.TransientModel):
_name = 'poynt.payment.wizard'
_description = 'Collect Poynt Payment'
invoice_id = fields.Many2one(
'account.move',
string="Invoice",
required=True,
readonly=True,
domain="[('move_type', 'in', ('out_invoice', 'out_refund'))]",
)
partner_id = fields.Many2one(
related='invoice_id.partner_id',
string="Customer",
)
amount = fields.Monetary(
string="Amount",
required=True,
currency_field='currency_id',
)
currency_id = fields.Many2one(
'res.currency',
string="Currency",
required=True,
readonly=True,
)
provider_id = fields.Many2one(
'payment.provider',
string="Poynt Provider",
required=True,
domain="[('code', '=', 'poynt'), ('state', '!=', 'disabled')]",
)
payment_mode = fields.Selection(
selection=[
('terminal', "Send to Terminal"),
('card', "Manual Card Entry"),
],
string="Payment Mode",
required=True,
default='terminal',
)
# --- Terminal fields ---
terminal_id = fields.Many2one(
'poynt.terminal',
string="Terminal",
domain="[('provider_id', '=', provider_id), ('active', '=', True)]",
)
# --- Card entry fields (never stored, transient only) ---
card_number = fields.Char(string="Card Number")
exp_month = fields.Char(string="Exp. Month", size=2)
exp_year = fields.Char(string="Exp. Year", size=4)
cvv = fields.Char(string="CVV", size=4)
cardholder_name = fields.Char(string="Cardholder Name")
# --- Status tracking for terminal mode ---
state = fields.Selection(
selection=[
('draft', "Draft"),
('waiting', "Waiting for Terminal"),
('done', "Payment Collected"),
('error', "Error"),
],
default='draft',
)
status_message = fields.Text(string="Status", readonly=True)
poynt_transaction_ref = fields.Char(readonly=True)
poynt_order_id = fields.Char(
string="Poynt Order ID",
readonly=True,
help="The Poynt order UUID created for this payment attempt. "
"Used to verify the correct terminal transaction.",
)
sent_at = fields.Datetime(
string="Sent At",
readonly=True,
help="Timestamp when the payment was sent to the terminal. "
"Used to filter out older transactions during status checks.",
)
transaction_id = fields.Many2one(
'payment.transaction',
string="Payment Transaction",
readonly=True,
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
invoice_id = self.env.context.get('active_id')
active_model = self.env.context.get('active_model')
if active_model == 'account.move' and invoice_id:
invoice = self.env['account.move'].browse(invoice_id)
res['invoice_id'] = invoice.id
res['amount'] = invoice.amount_residual
res['currency_id'] = invoice.currency_id.id
provider = self.env['payment.provider'].search([
('code', '=', 'poynt'),
('state', '!=', 'disabled'),
], limit=1)
if provider:
res['provider_id'] = provider.id
if provider.poynt_default_terminal_id:
res['terminal_id'] = provider.poynt_default_terminal_id.id
return res
@api.onchange('provider_id')
def _onchange_provider_id(self):
if self.provider_id and self.provider_id.poynt_default_terminal_id:
self.terminal_id = self.provider_id.poynt_default_terminal_id
def action_collect_payment(self):
"""Dispatch to the appropriate payment method."""
self.ensure_one()
if self.amount <= 0:
raise UserError(_("Payment amount must be greater than zero."))
self._cleanup_draft_transaction()
if self.payment_mode == 'terminal':
return self._process_terminal_payment()
elif self.payment_mode == 'card':
return self._process_card_payment()
def _process_terminal_payment(self):
"""Send a payment request to the physical Poynt terminal."""
self.ensure_one()
if not self.terminal_id:
raise UserError(_("Please select a terminal."))
tx = self._create_payment_transaction()
reference = tx.reference
try:
order_data = self._create_poynt_order(reference)
order_id = order_data.get('id', '')
tx.poynt_order_id = order_id
self.terminal_id.action_send_payment_to_terminal(
amount=self.amount,
currency=self.currency_id,
reference=reference,
order_id=order_id,
)
self.write({
'state': 'waiting',
'status_message': _(
"Payment request sent to terminal '%(terminal)s'. "
"Please complete the transaction on the device.",
terminal=self.terminal_id.name,
),
'poynt_transaction_ref': reference,
'poynt_order_id': order_id,
'sent_at': fields.Datetime.now(),
})
return self._open_poll_action()
except (ValidationError, UserError) as e:
self._cleanup_draft_transaction()
self.write({
'state': 'error',
'status_message': str(e),
})
return self._reopen_wizard()
def _process_card_payment(self):
"""Process a manual card entry payment via Poynt Cloud API."""
self.ensure_one()
self._validate_card_fields()
tx = self._create_payment_transaction()
reference = tx.reference
try:
order_data = self._create_poynt_order(reference)
order_id = order_data.get('id', '')
tx.poynt_order_id = order_id
funding_source = {
'type': 'CREDIT_DEBIT',
'card': {
'number': self.card_number.replace(' ', ''),
'expirationMonth': int(self.exp_month),
'expirationYear': int(self.exp_year),
'cardHolderFullName': self.cardholder_name or '',
},
'verificationData': {
'cvData': self.cvv,
},
'entryDetails': {
'customerPresenceStatus': 'MOTO',
'entryMode': 'KEYED',
},
}
action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE'
minor_amount = poynt_utils.format_poynt_amount(
self.amount, self.currency_id,
)
txn_payload = {
'action': action,
'amounts': {
'transactionAmount': minor_amount,
'orderAmount': minor_amount,
'tipAmount': 0,
'cashbackAmount': 0,
'currency': self.currency_id.name,
},
'fundingSource': funding_source,
'context': {
'source': 'WEB',
'sourceApp': 'odoo.fusion_poynt',
'transactionInstruction': 'ONLINE_AUTH_REQUIRED',
'businessId': self.provider_id.poynt_business_id,
},
'notes': reference,
}
if order_id:
txn_payload['references'] = [{
'id': order_id,
'type': 'POYNT_ORDER',
}]
result = self.provider_id._poynt_make_request(
'POST', 'transactions', payload=txn_payload,
)
transaction_id = result.get('id', '')
status = result.get('status', '')
tx.write({
'poynt_transaction_id': transaction_id,
'provider_reference': transaction_id,
})
payment_data = {
'reference': reference,
'poynt_transaction_id': transaction_id,
'poynt_order_id': order_id,
'poynt_status': status,
'funding_source': result.get('fundingSource', {}),
}
tx._process('poynt', payment_data)
self.write({
'state': 'done',
'status_message': _(
"Payment collected successfully. Transaction: %(txn_id)s",
txn_id=transaction_id,
),
'poynt_transaction_ref': transaction_id,
})
return self._reopen_wizard()
except (ValidationError, UserError) as e:
self._cleanup_draft_transaction()
self.write({
'state': 'error',
'status_message': str(e),
})
return self._reopen_wizard()
def action_check_status(self):
"""Poll the terminal for payment status (used in waiting state).
Uses the Poynt order ID to look up transactions associated with
the specific order we created for this payment attempt. Falls
back to referenceId search with time filtering to avoid matching
transactions from previous attempts.
Returns False if still pending (JS poller keeps going), or an
act_window action to show the final result.
"""
self.ensure_one()
if not self.poynt_transaction_ref:
raise UserError(_("No payment reference to check."))
terminal = self.terminal_id
if not terminal:
raise UserError(_("No terminal associated with this payment."))
provider = self.provider_id
try:
txn = self._find_terminal_transaction(provider)
except (ValidationError, UserError):
return False
if not txn:
return False
status = txn.get('status', 'UNKNOWN')
transaction_id = txn.get('id', '')
if status in ('CAPTURED', 'AUTHORIZED', 'SETTLED'):
tx = self.transaction_id
if tx:
tx.write({
'poynt_transaction_id': transaction_id,
'provider_reference': transaction_id,
})
payment_data = {
'reference': self.poynt_transaction_ref,
'poynt_transaction_id': transaction_id,
'poynt_status': status,
'funding_source': txn.get('fundingSource', {}),
}
tx._process('poynt', payment_data)
self.write({
'state': 'done',
'status_message': _(
"Payment collected successfully on terminal."
),
})
return self._reopen_wizard()
if status in ('DECLINED', 'VOIDED', 'REFUNDED'):
self._cleanup_draft_transaction()
self.write({
'state': 'error',
'status_message': _(
"Payment was %(status)s on the terminal.",
status=status.lower(),
),
})
return self._reopen_wizard()
return False
def _find_terminal_transaction(self, provider):
"""Locate the Poynt transaction for the current payment attempt.
Strategy:
1. If we have a poynt_order_id, fetch the order from Poynt and
check its linked transactions for a completed payment.
2. Fall back to searching transactions by referenceId, but only
accept transactions created after we sent the cloud message
(self.sent_at) to avoid matching older transactions.
:return: The matching Poynt transaction dict, or None if pending.
:rtype: dict | None
"""
if self.poynt_order_id:
txn = self._check_order_transactions(provider)
if txn:
return txn
return self._check_by_reference_with_time_filter(provider)
def _check_order_transactions(self, provider):
"""Look up transactions linked to our Poynt order."""
try:
order_data = provider._poynt_make_request(
'GET', f'orders/{self.poynt_order_id}',
)
except (ValidationError, UserError):
_logger.debug("Could not fetch Poynt order %s", self.poynt_order_id)
return None
txn_ids = []
for item in order_data.get('transactions', []):
tid = item if isinstance(item, str) else item.get('id', '')
if tid:
txn_ids.append(tid)
for tid in txn_ids:
try:
txn_data = provider._poynt_make_request(
'GET', f'transactions/{tid}',
)
status = txn_data.get('status', '')
if status in ('CAPTURED', 'AUTHORIZED', 'SETTLED',
'DECLINED', 'VOIDED', 'REFUNDED'):
return txn_data
except (ValidationError, UserError):
continue
return None
def _check_by_reference_with_time_filter(self, provider):
"""Search transactions by referenceId, filtering by creation time."""
sent_at_str = ''
if self.sent_at:
sent_at_str = self.sent_at.strftime('%Y-%m-%dT%H:%M:%SZ')
params = {
'referenceId': self.poynt_transaction_ref,
'limit': 5,
}
if sent_at_str:
params['startAt'] = sent_at_str
try:
txn_result = provider._poynt_make_request(
'GET', 'transactions', params=params,
)
except (ValidationError, UserError):
return None
transactions = txn_result.get('transactions', [])
if not transactions and not sent_at_str:
try:
txn_result = provider._poynt_make_request(
'GET', 'transactions',
params={'notes': self.poynt_transaction_ref, 'limit': 5},
)
transactions = txn_result.get('transactions', [])
except (ValidationError, UserError):
return None
for txn in transactions:
status = txn.get('status', 'UNKNOWN')
if status in ('CAPTURED', 'AUTHORIZED', 'SETTLED',
'DECLINED', 'VOIDED', 'REFUNDED'):
return txn
return None
def action_send_receipt(self):
"""Email the payment receipt to the customer and close the wizard."""
self.ensure_one()
tx = self.transaction_id
if not tx:
raise UserError(_("No payment transaction found."))
template = self.env.ref(
'fusion_poynt.mail_template_poynt_receipt', raise_if_not_found=False,
)
if not template:
raise UserError(_("Receipt email template not found."))
template.send_mail(tx.id, force_send=True)
return {'type': 'ir.actions.act_window_close'}
def action_cancel_payment(self):
"""Cancel the payment and clean up the draft transaction."""
self.ensure_one()
self._cleanup_draft_transaction()
return {'type': 'ir.actions.act_window_close'}
def _cleanup_draft_transaction(self):
"""Remove the draft payment transaction created by this wizard."""
if not self.transaction_id:
return
tx = self.transaction_id.sudo()
if tx.state == 'draft':
tx.invoice_ids = [(5,)]
tx.unlink()
self.transaction_id = False
# === HELPERS === #
def _validate_card_fields(self):
"""Validate that card entry fields are properly filled."""
if not self.card_number or len(self.card_number.replace(' ', '')) < 13:
raise UserError(_("Please enter a valid card number."))
if not self.exp_month or not self.exp_month.isdigit():
raise UserError(_("Please enter a valid expiry month (01-12)."))
if not self.exp_year or not self.exp_year.isdigit() or len(self.exp_year) < 2:
raise UserError(_("Please enter a valid expiry year."))
if not self.cvv or not self.cvv.isdigit():
raise UserError(_("Please enter the CVV."))
def _create_payment_transaction(self):
"""Create a payment.transaction linked to the invoice."""
payment_method = self.env['payment.method'].search(
[('code', '=', 'card')], limit=1,
)
if not payment_method:
payment_method = self.env['payment.method'].search(
[('code', 'in', ('visa', 'mastercard'))], limit=1,
)
if not payment_method:
raise UserError(
_("No card payment method found. Please configure one "
"in Settings > Payment Methods.")
)
tx_values = {
'provider_id': self.provider_id.id,
'payment_method_id': payment_method.id,
'amount': self.amount,
'currency_id': self.currency_id.id,
'partner_id': self.partner_id.id,
'operation': 'offline',
'invoice_ids': [(4, self.invoice_id.id)],
}
tx = self.env['payment.transaction'].sudo().create(tx_values)
self.transaction_id = tx
return tx
def _create_poynt_order(self, reference):
"""Create a Poynt order via the API."""
order_payload = poynt_utils.build_order_payload(
reference,
self.amount,
self.currency_id,
business_id=self.provider_id.poynt_business_id,
store_id=self.provider_id.poynt_store_id or '',
)
return self.provider_id._poynt_make_request(
'POST', 'orders', payload=order_payload,
)
def _reopen_wizard(self):
"""Return an action that re-opens this wizard record (keeps state)."""
return {
'type': 'ir.actions.act_window',
'name': _("Collect Poynt Payment"),
'res_model': self._name,
'res_id': self.id,
'views': [(False, 'form')],
'target': 'new',
}
def _open_poll_action(self):
"""Return a client action that auto-polls the terminal status."""
return {
'type': 'ir.actions.client',
'tag': 'poynt_poll_action',
'name': _("Waiting for Terminal"),
'target': 'new',
'params': {
'wizard_id': self.id,
},
}

View File

@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="poynt_payment_wizard_form" model="ir.ui.view">
<field name="name">poynt.payment.wizard.form</field>
<field name="model">poynt.payment.wizard</field>
<field name="arch" type="xml">
<form string="Collect Poynt Payment">
<field name="state" invisible="1"/>
<field name="poynt_transaction_ref" invisible="1"/>
<!-- Status banner for waiting / done / error -->
<div class="alert alert-info" role="alert"
invisible="state != 'waiting'">
<strong>Waiting for terminal...</strong>
<field name="status_message" nolabel="1"/>
</div>
<div class="alert alert-success" role="alert"
invisible="state != 'done'">
<strong>Payment Collected</strong>
<br/>
<field name="status_message" nolabel="1"/>
</div>
<div class="alert alert-danger" role="alert"
invisible="state != 'error'">
<strong>Error</strong>
<br/>
<field name="status_message" nolabel="1"/>
</div>
<group invisible="state == 'done'">
<group string="Payment Details">
<field name="invoice_id"/>
<field name="partner_id"/>
<field name="amount"/>
<field name="currency_id"/>
<field name="provider_id"
readonly="state != 'draft'"/>
</group>
<group string="Payment Mode"
invisible="state not in ('draft', 'error')">
<field name="payment_mode" widget="radio"
readonly="state not in ('draft', 'error')"/>
</group>
</group>
<!-- Terminal section -->
<group string="Terminal"
invisible="payment_mode != 'terminal' or state == 'done'">
<field name="terminal_id"
required="payment_mode == 'terminal' and state in ('draft', 'error')"
readonly="state == 'waiting'"/>
</group>
<!-- Card entry section -->
<group string="Card Details"
invisible="payment_mode != 'card' or state == 'done'">
<group>
<field name="card_number"
placeholder="4111 1111 1111 1111"
required="payment_mode == 'card' and state in ('draft', 'error')"
password="True"/>
<field name="cardholder_name"
placeholder="Name on card"/>
</group>
<group>
<field name="exp_month"
placeholder="MM"
required="payment_mode == 'card' and state in ('draft', 'error')"/>
<field name="exp_year"
placeholder="YYYY"
required="payment_mode == 'card' and state in ('draft', 'error')"/>
<field name="cvv"
placeholder="123"
required="payment_mode == 'card' and state in ('draft', 'error')"
password="True"/>
</group>
</group>
<footer>
<!-- Draft / Error state: show action buttons -->
<button string="Send to Terminal"
name="action_collect_payment"
type="object"
class="btn-primary"
invisible="payment_mode != 'terminal' or state not in ('draft', 'error')"
data-hotkey="q"/>
<button string="Collect Payment"
name="action_collect_payment"
type="object"
class="btn-primary"
invisible="payment_mode != 'card' or state not in ('draft', 'error')"
data-hotkey="q"/>
<!-- Waiting state: check status + cancel -->
<button string="Check Status"
name="action_check_status"
type="object"
class="btn-primary"
invisible="state != 'waiting'"
data-hotkey="q"/>
<button string="Cancel Payment"
name="action_cancel_payment"
type="object"
class="btn-secondary"
invisible="state not in ('waiting', 'error')"
data-hotkey="x"/>
<!-- Done state: send receipt + close -->
<button string="Send Receipt"
name="action_send_receipt"
type="object"
class="btn-primary"
icon="fa-envelope"
invisible="state != 'done'"
data-hotkey="s"/>
<button string="Close"
class="btn-secondary"
special="cancel"
invisible="state != 'done'"
data-hotkey="x"/>
<!-- Draft state: cancel cleans up -->
<button string="Cancel"
name="action_cancel_payment"
type="object"
class="btn-secondary"
invisible="state != 'draft'"
data-hotkey="x"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,531 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.addons.fusion_poynt import utils as poynt_utils
_logger = logging.getLogger(__name__)
REFERENCED_REFUND_LIMIT_DAYS = 180
class PoyntRefundWizard(models.TransientModel):
_name = 'poynt.refund.wizard'
_description = 'Refund via Poynt'
credit_note_id = fields.Many2one(
'account.move',
string="Credit Note",
required=True,
readonly=True,
)
original_invoice_id = fields.Many2one(
'account.move',
string="Original Invoice",
readonly=True,
)
partner_id = fields.Many2one(
related='credit_note_id.partner_id',
string="Customer",
)
amount = fields.Monetary(
string="Refund Amount",
required=True,
currency_field='currency_id',
)
currency_id = fields.Many2one(
'res.currency',
string="Currency",
required=True,
readonly=True,
)
provider_id = fields.Many2one(
'payment.provider',
string="Poynt Provider",
required=True,
readonly=True,
)
original_transaction_id = fields.Many2one(
'payment.transaction',
string="Original Transaction",
readonly=True,
)
original_poynt_txn_id = fields.Char(
string="Poynt Transaction ID",
readonly=True,
)
card_info = fields.Char(
string="Card Used",
readonly=True,
)
transaction_age_days = fields.Integer(
string="Transaction Age (days)",
readonly=True,
)
refund_type = fields.Selection(
selection=[
('referenced', "Referenced Refund"),
('non_referenced', "Non-Referenced Credit"),
],
string="Refund Method",
readonly=True,
)
refund_type_note = fields.Text(
string="Note",
readonly=True,
)
terminal_id = fields.Many2one(
'poynt.terminal',
string="Terminal",
domain="[('provider_id', '=', provider_id), ('active', '=', True)]",
help="Terminal to process the non-referenced credit on.",
)
refund_transaction_id = fields.Many2one(
'payment.transaction',
string="Refund Transaction",
readonly=True,
)
state = fields.Selection(
selection=[
('confirm', "Confirm"),
('done', "Refunded"),
('error', "Error"),
],
default='confirm',
)
status_message = fields.Text(string="Status", readonly=True)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
credit_note_id = self.env.context.get('active_id')
active_model = self.env.context.get('active_model')
if active_model != 'account.move' or not credit_note_id:
return res
credit_note = self.env['account.move'].browse(credit_note_id)
res['credit_note_id'] = credit_note.id
res['amount'] = abs(credit_note.amount_residual) or abs(credit_note.amount_total)
res['currency_id'] = credit_note.currency_id.id
orig_tx = credit_note._get_original_poynt_transaction()
if not orig_tx:
raise UserError(_(
"No Poynt payment transaction found for the original invoice. "
"This credit note cannot be refunded via Poynt."
))
res['original_transaction_id'] = orig_tx.id
res['provider_id'] = orig_tx.provider_id.id
res['original_invoice_id'] = credit_note.reversed_entry_id.id
res['original_poynt_txn_id'] = orig_tx.poynt_transaction_id
if orig_tx.provider_id.poynt_default_terminal_id:
res['terminal_id'] = orig_tx.provider_id.poynt_default_terminal_id.id
age_days = 0
if orig_tx.create_date:
age_days = (fields.Datetime.now() - orig_tx.create_date).days
res['transaction_age_days'] = age_days
if age_days > REFERENCED_REFUND_LIMIT_DAYS:
res['refund_type'] = 'non_referenced'
res['refund_type_note'] = _(
"This transaction is %(days)s days old (limit is %(limit)s "
"days). A non-referenced credit will be issued. This "
"requires the customer's card to be present on the terminal.",
days=age_days,
limit=REFERENCED_REFUND_LIMIT_DAYS,
)
else:
res['refund_type'] = 'referenced'
res['refund_type_note'] = _(
"This transaction is %(days)s days old (within the %(limit)s-day "
"limit). A referenced refund will be issued back to the "
"original card automatically.",
days=age_days,
limit=REFERENCED_REFUND_LIMIT_DAYS,
)
receipt_data = orig_tx.poynt_receipt_data
if receipt_data:
try:
data = json.loads(receipt_data)
card_type = data.get('card_type', '')
card_last4 = data.get('card_last4', '')
if card_type or card_last4:
res['card_info'] = f"{card_type} ****{card_last4}"
except (ValueError, KeyError):
pass
return res
def action_process_refund(self):
"""Dispatch to referenced refund or non-referenced credit."""
self.ensure_one()
if self.amount <= 0:
raise UserError(_("Refund amount must be greater than zero."))
orig_tx = self.original_transaction_id
if orig_tx.poynt_voided:
raise UserError(_(
"This transaction was already voided on Poynt on %(date)s. "
"A voided transaction cannot also be refunded -- the charge "
"was already reversed before settlement.",
date=orig_tx.poynt_void_date,
))
self._verify_transaction_not_already_reversed()
if self.refund_type == 'non_referenced':
return self._process_non_referenced_credit()
return self._process_referenced_refund()
def _verify_transaction_not_already_reversed(self):
"""Check on Poynt that the transaction and all linked children
have not been voided or refunded.
For SALE transactions Poynt creates AUTHORIZE + CAPTURE children.
A void/refund may target the capture child, leaving the parent
still showing ``status: CAPTURED``. We must check the full chain.
"""
orig_tx = self.original_transaction_id
provider = self.provider_id
txn_id = orig_tx.poynt_transaction_id
try:
txn_data = provider._poynt_make_request(
'GET', f'transactions/{txn_id}',
)
except (ValidationError, Exception):
_logger.debug("Could not verify transaction %s on Poynt", txn_id)
return
self._check_txn_reversed(txn_data, orig_tx)
for link in txn_data.get('links', []):
child_id = link.get('href', '')
if not child_id:
continue
try:
child_data = provider._poynt_make_request(
'GET', f'transactions/{child_id}',
)
self._check_txn_reversed(child_data, orig_tx)
except (ValidationError, Exception):
continue
def _check_txn_reversed(self, txn_data, orig_tx):
"""Raise if the given Poynt transaction has been voided or refunded."""
txn_id = txn_data.get('id', '?')
if txn_data.get('voided'):
_logger.warning(
"Poynt txn %s is voided, blocking refund", txn_id,
)
if not orig_tx.poynt_voided:
orig_tx.sudo().write({
'state': 'cancel',
'poynt_voided': True,
'poynt_void_date': fields.Datetime.now(),
})
raise UserError(_(
"This transaction (%(txn_id)s) has already been voided on "
"Poynt. The charge was reversed before settlement -- no "
"refund is needed.",
txn_id=txn_id,
))
status = txn_data.get('status', '')
if status == 'REFUNDED':
_logger.warning(
"Poynt txn %s is already refunded, blocking duplicate", txn_id,
)
raise UserError(_(
"This transaction (%(txn_id)s) has already been refunded on "
"Poynt. A duplicate refund cannot be issued.",
txn_id=txn_id,
))
if status == 'VOIDED':
_logger.warning(
"Poynt txn %s has VOIDED status, blocking refund", txn_id,
)
if not orig_tx.poynt_voided:
orig_tx.sudo().write({
'state': 'cancel',
'poynt_voided': True,
'poynt_void_date': fields.Datetime.now(),
})
raise UserError(_(
"This transaction (%(txn_id)s) has already been voided on "
"Poynt. The charge was reversed before settlement -- no "
"refund is needed.",
txn_id=txn_id,
))
def _process_referenced_refund(self):
"""Send a referenced REFUND using the original transaction's parentId."""
orig_tx = self.original_transaction_id
provider = self.provider_id
parent_txn_id = orig_tx.poynt_transaction_id
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, orig_tx.poynt_transaction_id,
)
break
except (ValidationError, Exception):
_logger.debug(
"Could not fetch parent txn %s, using original ID",
parent_txn_id,
)
minor_amount = poynt_utils.format_poynt_amount(
self.amount, self.currency_id,
)
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 {orig_tx.reference} via {self.credit_note_id.name}',
}
try:
result = provider._poynt_make_request(
'POST', 'transactions', payload=refund_payload,
)
except (ValidationError, UserError) as e:
self.write({
'state': 'error',
'status_message': str(e),
})
return self._reopen_wizard()
return self._handle_refund_result(result, orig_tx)
def _process_non_referenced_credit(self):
"""Send a non-referenced credit via cloud message to the terminal.
Required when the original transaction is older than 180 days.
The customer's card must be present on the terminal.
"""
if not self.terminal_id:
raise UserError(_(
"A terminal is required for non-referenced credits. "
"The customer's card must be present on the device."
))
provider = self.provider_id
orig_tx = self.original_transaction_id
minor_amount = poynt_utils.format_poynt_amount(
self.amount, self.currency_id,
)
reference = f"NRC-{self.credit_note_id.name}"
payment_data = json.dumps({
'amount': minor_amount,
'currency': self.currency_id.name,
'nonReferencedCredit': True,
'referenceId': reference,
'callbackUrl': '',
})
cloud_payload = {
'ttl': 120,
'businessId': provider.poynt_business_id,
'storeId': provider.poynt_store_id,
'deviceId': self.terminal_id.terminal_id,
'data': json.dumps({
'action': 'non-referenced-credit',
'purchaseAmount': minor_amount,
'currency': self.currency_id.name,
'referenceId': reference,
'payment': payment_data,
}),
}
try:
provider._poynt_make_request(
'POST', 'cloudMessages',
payload=cloud_payload,
business_scoped=False,
)
except (ValidationError, UserError) as e:
self.write({
'state': 'error',
'status_message': str(e),
})
return self._reopen_wizard()
refund_tx = self._create_refund_transaction(
orig_tx,
refund_txn_id='',
refund_status='PENDING',
)
self.credit_note_id.sudo().poynt_refunded = True
self.credit_note_id.sudo().message_post(
body=_(
"Non-referenced credit sent to terminal '%(terminal)s'. "
"Amount: %(amount)s %(currency)s. "
"The customer must present their card on the terminal to "
"complete the refund.",
terminal=self.terminal_id.name,
amount=self.amount,
currency=self.currency_id.name,
),
)
self.write({
'state': 'done',
'status_message': _(
"Non-referenced credit of %(amount)s %(currency)s sent to "
"terminal '%(terminal)s'. Please ask the customer to present "
"their card on the terminal to complete the refund.",
amount=self.amount,
currency=self.currency_id.name,
terminal=self.terminal_id.name,
),
})
return self._reopen_wizard()
def _handle_refund_result(self, result, orig_tx):
"""Process the Poynt API response for a referenced refund."""
refund_status = result.get('status', '')
refund_txn_id = result.get('id', '')
_logger.info(
"Poynt refund response: status=%s, id=%s",
refund_status, refund_txn_id,
)
if refund_status in ('DECLINED', 'FAILED'):
self.write({
'state': 'error',
'status_message': _(
"Refund declined by the payment processor. "
"Status: %(status)s. Please try again or contact support.",
status=refund_status,
),
})
return self._reopen_wizard()
refund_tx = self._create_refund_transaction(orig_tx, refund_txn_id, refund_status)
self.refund_transaction_id = refund_tx
self.credit_note_id.sudo().poynt_refunded = True
self.credit_note_id.sudo().message_post(
body=_(
"Refund processed via Poynt. Amount: %(amount)s %(currency)s. "
"Poynt Transaction ID: %(txn_id)s.",
amount=self.amount,
currency=self.currency_id.name,
txn_id=refund_txn_id,
),
)
self.write({
'state': 'done',
'status_message': _(
"Refund of %(amount)s %(currency)s processed successfully. "
"The refund will appear on the customer's card within "
"3-5 business days.",
amount=self.amount,
currency=self.currency_id.name,
),
})
return self._reopen_wizard()
def _create_refund_transaction(self, orig_tx, refund_txn_id, refund_status):
"""Create a payment.transaction for the refund."""
payment_method = self.env['payment.method'].search(
[('code', '=', 'card')], limit=1,
) or self.env['payment.method'].search(
[('code', 'in', ('visa', 'mastercard'))], limit=1,
)
refund_tx = self.env['payment.transaction'].sudo().create({
'provider_id': self.provider_id.id,
'payment_method_id': payment_method.id if payment_method else False,
'amount': -self.amount,
'currency_id': self.currency_id.id,
'partner_id': self.partner_id.id,
'operation': 'refund',
'source_transaction_id': orig_tx.id,
'provider_reference': refund_txn_id or '',
'poynt_transaction_id': refund_txn_id or '',
'invoice_ids': [(4, self.credit_note_id.id)],
})
if refund_txn_id and refund_status not in ('PENDING',):
payment_data = {
'reference': refund_tx.reference,
'poynt_transaction_id': refund_txn_id,
'poynt_status': refund_status,
}
refund_tx._process('poynt', payment_data)
return refund_tx
def action_send_receipt(self):
"""Email the refund receipt to the customer and close the wizard."""
self.ensure_one()
tx = self.refund_transaction_id
if not tx:
raise UserError(_("No refund transaction found."))
template = self.env.ref(
'fusion_poynt.mail_template_poynt_receipt', raise_if_not_found=False,
)
if not template:
raise UserError(_("Receipt email template not found."))
template.send_mail(tx.id, force_send=True)
return {'type': 'ir.actions.act_window_close'}
def _reopen_wizard(self):
return {
'type': 'ir.actions.act_window',
'name': _("Refund via Poynt"),
'res_model': self._name,
'res_id': self.id,
'views': [(False, 'form')],
'target': 'new',
}

View File

@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="poynt_refund_wizard_form" model="ir.ui.view">
<field name="name">poynt.refund.wizard.form</field>
<field name="model">poynt.refund.wizard</field>
<field name="arch" type="xml">
<form string="Refund via Poynt">
<!-- Success banner -->
<div class="alert alert-success text-center"
role="status"
invisible="state != 'done'">
<strong>Refund Processed Successfully</strong>
<p><field name="status_message" nolabel="1" readonly="1"/></p>
</div>
<!-- Error banner -->
<div class="alert alert-danger text-center"
role="alert"
invisible="state != 'error'">
<strong>Refund Failed</strong>
<p><field name="status_message" nolabel="1" readonly="1"/></p>
</div>
<!-- Non-referenced credit warning -->
<div class="alert alert-warning text-center"
role="alert"
invisible="state != 'confirm' or refund_type != 'non_referenced'">
<strong>Non-Referenced Credit Required</strong>
<p>
The original transaction is over 180 days old.
The customer's card must be physically present on the
terminal to process this refund.
</p>
</div>
<group invisible="state == 'done'">
<group string="Refund Details">
<field name="credit_note_id" readonly="1"/>
<field name="original_invoice_id" readonly="1"/>
<field name="partner_id" readonly="1"/>
<field name="amount" readonly="state != 'confirm'"/>
<field name="currency_id" invisible="1"/>
</group>
<group string="Original Payment">
<field name="provider_id" readonly="1"/>
<field name="original_transaction_id" readonly="1"/>
<field name="original_poynt_txn_id" readonly="1"/>
<field name="card_info" readonly="1"
invisible="not card_info"/>
<field name="transaction_age_days" readonly="1"/>
<field name="refund_type" readonly="1"/>
</group>
</group>
<!-- Terminal selector for non-referenced credits -->
<group invisible="state != 'confirm' or refund_type != 'non_referenced'">
<group string="Terminal">
<field name="terminal_id"
required="refund_type == 'non_referenced'"
options="{'no_create': True}"/>
</group>
</group>
<!-- Refund method note -->
<group invisible="state != 'confirm'">
<field name="refund_type_note" readonly="1" nolabel="1"
widget="text" colspan="2"/>
</group>
<footer>
<!-- Confirm state -->
<button string="Process Refund"
name="action_process_refund"
type="object"
class="btn-primary"
icon="fa-undo"
invisible="state != 'confirm'"
confirm="Are you sure you want to refund this amount? This cannot be undone."
data-hotkey="q"/>
<button string="Cancel"
class="btn-secondary"
special="cancel"
invisible="state != 'confirm'"
data-hotkey="x"/>
<!-- Done state -->
<button string="Send Receipt"
name="action_send_receipt"
type="object"
class="btn-primary"
icon="fa-envelope"
invisible="state != 'done'"
data-hotkey="s"/>
<button string="Close"
class="btn-secondary"
special="cancel"
invisible="state != 'done'"
data-hotkey="x"/>
<!-- Error state -->
<button string="Close"
class="btn-primary"
special="cancel"
invisible="state != 'error'"
data-hotkey="x"/>
</footer>
</form>
</field>
</record>
</odoo>