changes
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
|
||||
|
||||
def post_init_hook(env):
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
97
fusion_poynt/data/poynt_receipt_email_template.xml
Normal file
97
fusion_poynt/data/poynt_receipt_email_template.xml
Normal 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 < 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>
|
||||
@@ -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
|
||||
|
||||
203
fusion_poynt/models/account_move.py
Normal file
203
fusion_poynt/models/account_move.py
Normal 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
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
60
fusion_poynt/models/sale_order.py
Normal file
60
fusion_poynt/models/sale_order.py
Normal 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,
|
||||
},
|
||||
}
|
||||
14
fusion_poynt/report/poynt_receipt_report.xml
Normal file
14
fusion_poynt/report/poynt_receipt_report.xml
Normal 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>
|
||||
303
fusion_poynt/report/poynt_receipt_templates.xml
Normal file
303
fusion_poynt/report/poynt_receipt_templates.xml
Normal 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 < 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>
|
||||
@@ -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
|
||||
|
||||
|
BIN
fusion_poynt/static/src/img/poynt_logo.png
Normal file
BIN
fusion_poynt/static/src/img/poynt_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
69
fusion_poynt/static/src/js/poynt_wizard_poll.js
Normal file
69
fusion_poynt/static/src/js/poynt_wizard_poll.js
Normal 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);
|
||||
27
fusion_poynt/static/src/xml/poynt_wizard_poll.xml
Normal file
27
fusion_poynt/static/src/xml/poynt_wizard_poll.xml
Normal 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>
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
83
fusion_poynt/views/account_move_views.xml
Normal file
83
fusion_poynt/views/account_move_views.xml
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
40
fusion_poynt/views/payment_transaction_views.xml
Normal file
40
fusion_poynt/views/payment_transaction_views.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
22
fusion_poynt/views/sale_order_views.xml
Normal file
22
fusion_poynt/views/sale_order_views.xml
Normal 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>
|
||||
4
fusion_poynt/wizard/__init__.py
Normal file
4
fusion_poynt/wizard/__init__.py
Normal 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
|
||||
552
fusion_poynt/wizard/poynt_payment_wizard.py
Normal file
552
fusion_poynt/wizard/poynt_payment_wizard.py
Normal 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,
|
||||
},
|
||||
}
|
||||
135
fusion_poynt/wizard/poynt_payment_wizard_views.xml
Normal file
135
fusion_poynt/wizard/poynt_payment_wizard_views.xml
Normal 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>
|
||||
531
fusion_poynt/wizard/poynt_refund_wizard.py
Normal file
531
fusion_poynt/wizard/poynt_refund_wizard.py
Normal 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',
|
||||
}
|
||||
112
fusion_poynt/wizard/poynt_refund_wizard_views.xml
Normal file
112
fusion_poynt/wizard/poynt_refund_wizard_views.xml
Normal 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>
|
||||
Reference in New Issue
Block a user