This commit is contained in:
gsinghpal
2026-03-26 15:16:51 -04:00
parent bd7275881f
commit 2563208f53
13 changed files with 329 additions and 15 deletions

View File

@@ -467,7 +467,9 @@ class PoyntController(http.Controller):
def poynt_process_card(self, reference=None, poynt_order_id=None,
card_number=None, exp_month=None, exp_year=None,
cvv=None, cardholder_name=None, card_type=None,
**kwargs):
billing_address=None, billing_city=None,
billing_state=None, billing_zip=None,
billing_country=None, **kwargs):
"""Process a card payment through Poynt Cloud API.
The frontend sends card details which are passed to Poynt for
@@ -503,6 +505,13 @@ class PoyntController(http.Controller):
},
'verificationData': {
'cvData': cvv,
'cardHolderBillingAddress': {
'line1': billing_address or '',
'city': billing_city or '',
'territory': billing_state or '',
'postalCode': billing_zip or '',
'countryCode': billing_country or '',
},
},
'entryDetails': {
'customerPresenceStatus': 'ECOMMERCE',
@@ -510,13 +519,25 @@ class PoyntController(http.Controller):
},
}
action = 'AUTHORIZE' if tx_sudo.provider_id.capture_manually else 'SALE'
provider = tx_sudo.provider_id.sudo()
action = 'AUTHORIZE' if provider.capture_manually else 'SALE'
minor_amount = poynt_utils.format_poynt_amount(
tx_sudo.amount, tx_sudo.currency_id,
)
context = {
'source': 'WEB',
'sourceApp': 'odoo.fusion_poynt',
'transactionInstruction': 'ONLINE_AUTH_REQUIRED',
}
if provider.poynt_business_id:
context['businessId'] = provider.poynt_business_id
if provider.poynt_store_id:
context['storeId'] = provider.poynt_store_id
txn_payload = {
'action': action,
'fundingSourceType': 'CREDIT_DEBIT',
'amounts': {
'transactionAmount': minor_amount,
'orderAmount': minor_amount,
@@ -525,11 +546,7 @@ class PoyntController(http.Controller):
'currency': tx_sudo.currency_id.name,
},
'fundingSource': funding_source,
'context': {
'source': 'WEB',
'sourceApp': 'odoo.fusion_poynt',
'transactionInstruction': 'ONLINE_AUTH_REQUIRED',
},
'context': context,
'notes': reference,
}
@@ -539,7 +556,7 @@ class PoyntController(http.Controller):
'type': 'POYNT_ORDER',
}]
result = tx_sudo.provider_id._poynt_make_request(
result = provider._poynt_make_request(
'POST', 'transactions', payload=txn_payload,
)

View File

@@ -23,6 +23,10 @@ class AccountMove(models.Model):
string="Has Poynt Receipt",
compute='_compute_has_poynt_receipt',
)
poynt_transaction_count = fields.Integer(
string="Poynt Transactions",
compute='_compute_poynt_transaction_count',
)
@api.depends('reversal_move_ids')
def _compute_poynt_refund_count(self):
@@ -38,6 +42,33 @@ class AccountMove(models.Model):
for move in self:
move.has_poynt_receipt = bool(move._get_poynt_transaction_for_receipt())
def _compute_poynt_transaction_count(self):
for move in self:
move.poynt_transaction_count = self.env['payment.transaction'].sudo().search_count([
('invoice_ids', 'in', move.id),
('provider_code', '=', 'poynt'),
])
def action_view_poynt_transactions(self):
"""Open payment transactions linked to this invoice/credit note."""
self.ensure_one()
transactions = self.env['payment.transaction'].sudo().search([
('invoice_ids', 'in', self.id),
('provider_code', '=', 'poynt'),
])
action = {
'name': _("Poynt Transactions"),
'type': 'ir.actions.act_window',
'res_model': 'payment.transaction',
'domain': [('id', 'in', transactions.ids)],
}
if len(transactions) == 1:
action['view_mode'] = 'form'
action['res_id'] = transactions.id
else:
action['view_mode'] = 'list,form'
return action
def action_view_poynt_refunds(self):
"""Open the credit notes linked to this invoice that were refunded via Poynt."""
self.ensure_one()

View File

@@ -58,6 +58,8 @@ class PaymentTransaction(models.Model):
For direct (online) payments we create a Poynt order upfront and return
identifiers plus the return URL so the frontend JS can complete the flow.
The actual transaction is created later when the frontend sends card
details via the /payment/poynt/process_card route.
"""
if self.provider_code != 'poynt':
return super()._get_specific_processing_values(processing_values)
@@ -65,7 +67,7 @@ class PaymentTransaction(models.Model):
if self.operation == 'online_token':
return {}
poynt_data = self._poynt_create_order_and_authorize()
order_data = self._poynt_create_order()
provider = self._get_provider_sudo()
base_url = provider.get_base_url()
@@ -75,8 +77,7 @@ class PaymentTransaction(models.Model):
)
return {
'poynt_order_id': poynt_data.get('order_id', ''),
'poynt_transaction_id': poynt_data.get('transaction_id', ''),
'poynt_order_id': order_data.get('order_id', ''),
'return_url': return_url,
'business_id': provider.poynt_business_id,
'is_test': provider.state == 'test',
@@ -108,6 +109,33 @@ class PaymentTransaction(models.Model):
return
self._process('poynt', payment_data)
def _poynt_create_order(self):
"""Create a Poynt order without a transaction.
Used by the portal payment flow where card details are collected
on the frontend and the transaction is created separately via
the /payment/poynt/process_card route.
:return: Dict with order_id.
:rtype: dict
"""
try:
provider = self._get_provider_sudo()
order_payload = poynt_utils.build_order_payload(
self.reference, self.amount, self.currency_id,
business_id=provider.poynt_business_id,
store_id=provider.poynt_store_id or '',
)
order_result = provider._poynt_make_request(
'POST', 'orders', payload=order_payload,
)
order_id = order_result.get('id', '')
self.poynt_order_id = order_id
return {'order_id': order_id}
except ValidationError as e:
self._set_error(str(e))
return {}
def _poynt_create_order_and_authorize(self):
"""Create a Poynt order and authorize the transaction.
@@ -383,6 +411,7 @@ class PaymentTransaction(models.Model):
try:
refund_payload = {
'action': 'REFUND',
'fundingSourceType': 'CREDIT_DEBIT',
'parentId': parent_txn_id,
'fundingSource': {
'type': 'CREDIT_DEBIT',
@@ -426,6 +455,7 @@ class PaymentTransaction(models.Model):
try:
capture_payload = {
'action': 'CAPTURE',
'fundingSourceType': 'CREDIT_DEBIT',
'parentId': source_tx.provider_reference,
'amounts': {
'transactionAmount': minor_amount,
@@ -817,6 +847,7 @@ class PaymentTransaction(models.Model):
self._poynt_store_receipt_data(payment_data)
self._poynt_attach_receipt_pdf()
self._poynt_attach_poynt_receipt()
self._poynt_auto_send_invoice_and_receipt()
except Exception:
_logger.exception(
"Receipt generation failed for transaction %s", self.reference,
@@ -925,6 +956,64 @@ class PaymentTransaction(models.Model):
'mimetype': 'text/html',
})
def _poynt_auto_send_invoice_and_receipt(self):
"""Automatically email the invoice and payment receipt to the customer
after a successful payment.
1. Sends the invoice via the standard Odoo invoice email template.
2. Sends the Poynt payment receipt email with the PDF attached.
Best-effort: failures are logged but never block the payment flow.
"""
self.ensure_one()
invoice = self.invoice_ids[:1]
partner = self.partner_id
if not partner.email:
_logger.info(
"Skipping auto-send for %s: partner %s has no email.",
self.reference, partner.display_name,
)
return
# 1. Send the invoice PDF
if invoice and invoice.state == 'posted':
try:
inv_template = self.env.ref(
'account.email_template_edi_invoice',
raise_if_not_found=False,
)
if inv_template:
inv_template.sudo().send_mail(
invoice.id, force_send=True,
)
invoice.sudo().write({'is_move_sent': True})
_logger.info(
"Auto-sent invoice %s to %s",
invoice.name, partner.email,
)
except Exception:
_logger.exception(
"Failed to auto-send invoice %s", invoice.name,
)
# 2. Send the payment receipt
try:
receipt_template = self.env.ref(
'fusion_poynt.mail_template_poynt_receipt',
raise_if_not_found=False,
)
if receipt_template:
receipt_template.sudo().send_mail(self.id, force_send=True)
_logger.info(
"Auto-sent payment receipt for %s to %s",
self.reference, partner.email,
)
except Exception:
_logger.exception(
"Failed to auto-send receipt for %s", self.reference,
)
def _get_poynt_receipt_values(self):
"""Parse the stored receipt JSON for use in QWeb templates.

View File

@@ -2,7 +2,7 @@
import logging
from odoo import _, models
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
@@ -11,6 +11,37 @@ _logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
poynt_transaction_count = fields.Integer(
string="Poynt Transactions",
compute='_compute_poynt_transaction_count',
)
def _compute_poynt_transaction_count(self):
for order in self:
order.poynt_transaction_count = self.env['payment.transaction'].sudo().search_count([
('sale_order_ids', 'in', order.id),
('provider_code', '=', 'poynt'),
])
def action_view_poynt_transactions(self):
self.ensure_one()
transactions = self.env['payment.transaction'].sudo().search([
('sale_order_ids', 'in', self.id),
('provider_code', '=', 'poynt'),
])
action = {
'name': _("Poynt Transactions"),
'type': 'ir.actions.act_window',
'res_model': 'payment.transaction',
'domain': [('id', 'in', transactions.ids)],
}
if len(transactions) == 1:
action['view_mode'] = 'form'
action['res_id'] = transactions.id
else:
action['view_mode'] = 'list,form'
return action
def action_poynt_collect_payment(self):
"""Create an invoice (if needed) and open the Poynt payment wizard.

View File

@@ -45,6 +45,22 @@ patch(PaymentForm.prototype, {
this._setupCardFormatting(poyntContainer);
this._setupTerminalToggle(poyntContainer);
this._setupSurcharge(poyntContainer);
this._prefillBillingAddress(poyntContainer);
},
_prefillBillingAddress(container) {
const billing = this.poyntFormData.billing_details;
if (!billing || !billing.address) return;
const addr = billing.address;
const setVal = (id, val) => {
const el = container.querySelector(id);
if (el && val) el.value = val;
};
setVal('#poynt_billing_address', addr.line1);
setVal('#poynt_billing_city', addr.city);
setVal('#poynt_billing_state', addr.state);
setVal('#poynt_billing_zip', addr.postal_code);
setVal('#poynt_billing_country', addr.country);
},
_detectCardBrand(number) {
@@ -317,6 +333,36 @@ patch(PaymentForm.prototype, {
return checked ? checked.value : 'other';
},
_showProcessingOverlay(container) {
const overlay = container.querySelector('.o_poynt_processing_overlay');
if (overlay) {
// Hide all form field sections
Array.from(container.children).forEach(child => {
if (!child.classList.contains('o_poynt_processing_overlay')) {
child.style.display = 'none';
}
});
overlay.style.display = 'block';
}
},
_hideProcessingOverlay(container) {
const overlay = container.querySelector('.o_poynt_processing_overlay');
if (overlay) {
overlay.style.display = 'none';
Array.from(container.children).forEach(child => {
if (!child.classList.contains('o_poynt_processing_overlay')) {
child.style.display = '';
}
});
}
},
_updateProcessingMessage(container, message) {
const msgEl = container.querySelector('.o_poynt_processing_message');
if (msgEl) msgEl.textContent = message;
},
async _processCardPayment(processingValues, inlineForm) {
const cardNumber = inlineForm.querySelector('#poynt_card_number').value.replace(/\D/g, '');
const expiry = inlineForm.querySelector('#poynt_expiry').value;
@@ -328,6 +374,13 @@ patch(PaymentForm.prototype, {
const [expMonth, expYear] = expiry.split('/').map(Number);
const formContainer = inlineForm.closest('.o_poynt_payment_form')
|| inlineForm.querySelector('.o_poynt_payment_form')
|| inlineForm;
// Show processing animation
this._showProcessingOverlay(formContainer);
try {
const result = await rpc('/payment/poynt/process_card', {
reference: processingValues.reference,
@@ -338,9 +391,15 @@ patch(PaymentForm.prototype, {
cvv: cvv,
cardholder_name: cardholder,
card_type: cardType,
billing_address: inlineForm.querySelector('#poynt_billing_address')?.value || '',
billing_city: inlineForm.querySelector('#poynt_billing_city')?.value || '',
billing_state: inlineForm.querySelector('#poynt_billing_state')?.value || '',
billing_zip: inlineForm.querySelector('#poynt_billing_zip')?.value || '',
billing_country: inlineForm.querySelector('#poynt_billing_country')?.value || '',
});
if (result.error) {
this._hideProcessingOverlay(formContainer);
this._displayErrorDialog(
_t("Payment Failed"),
result.error,
@@ -349,8 +408,10 @@ patch(PaymentForm.prototype, {
return;
}
this._updateProcessingMessage(formContainer, _t("Payment successful! Redirecting..."));
window.location.href = processingValues.return_url;
} catch (error) {
this._hideProcessingOverlay(formContainer);
this._displayErrorDialog(
_t("Payment Processing Error"),
error.message || _t("An unexpected error occurred."),

View File

@@ -269,6 +269,7 @@ def build_transaction_payload(
payload = {
'action': action,
'fundingSourceType': 'CREDIT_DEBIT',
'amounts': {
'transactionAmount': minor_amount,
'orderAmount': minor_amount,
@@ -318,6 +319,7 @@ def build_token_charge_payload(
payload = {
'action': action,
'fundingSourceType': 'CREDIT_DEBIT',
'context': context,
'amounts': {
'transactionAmount': minor_amount,

View File

@@ -8,8 +8,15 @@
<field name="priority">60</field>
<field name="arch" type="xml">
<!-- Poynt Refund smart button on invoices -->
<!-- Poynt Transaction smart button -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_poynt_transactions"
type="object"
class="oe_stat_button"
icon="fa-credit-card"
invisible="poynt_transaction_count == 0">
<field name="poynt_transaction_count" widget="statinfo" string="Poynt Payments"/>
</button>
<button name="action_view_poynt_refunds"
type="object"
class="oe_stat_button"

View File

@@ -62,6 +62,51 @@
autocomplete="cc-name"/>
</div>
<!-- Billing address -->
<div class="mb-3 o_poynt_billing_section">
<label class="form-label fw-bold">Billing Address</label>
<div class="mb-2">
<input type="text" class="form-control"
id="poynt_billing_address"
name="billing_address"
placeholder="Street Address"
autocomplete="billing street-address"/>
</div>
<div class="row mb-2">
<div class="col-6">
<input type="text" class="form-control"
id="poynt_billing_city"
name="billing_city"
placeholder="City"
autocomplete="billing address-level2"/>
</div>
<div class="col-6">
<input type="text" class="form-control"
id="poynt_billing_state"
name="billing_state"
placeholder="Province / State"
autocomplete="billing address-level1"/>
</div>
</div>
<div class="row mb-2">
<div class="col-6">
<input type="text" class="form-control"
id="poynt_billing_zip"
name="billing_zip"
placeholder="Postal Code"
autocomplete="billing postal-code"/>
</div>
<div class="col-6">
<input type="text" class="form-control"
id="poynt_billing_country"
name="billing_country"
placeholder="Country Code (CA, US)"
maxlength="2"
autocomplete="billing country"/>
</div>
</div>
</div>
<!-- Card type selector (for terminal payments where card brand cannot be auto-detected) -->
<div class="mb-3 o_poynt_card_type_section" style="display:none;">
<label class="form-label">Card Type</label>
@@ -117,6 +162,17 @@
</select>
</div>
</div>
<!-- Processing overlay (hidden until payment is submitted) -->
<div class="o_poynt_processing_overlay" style="display:none;">
<div class="text-center p-4">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<h5 class="o_poynt_processing_message">Processing your payment...</h5>
<p class="text-muted">Please do not close this page.</p>
</div>
</div>
</div>
</template>

View File

@@ -28,10 +28,16 @@
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 -->
<!-- Add Poynt fields and linked records after provider_reference -->
<xpath expr="//field[@name='provider_reference']" position="after">
<field name="poynt_voided" invisible="1"/>
<field name="poynt_void_date" invisible="not poynt_voided"/>
<field name="poynt_order_id" invisible="provider_code != 'poynt'" readonly="1"/>
<field name="poynt_transaction_id" invisible="provider_code != 'poynt'" readonly="1"/>
<field name="sale_order_ids" widget="many2many_tags" readonly="1"
string="Sale Orders"/>
<field name="invoice_ids" widget="many2many_tags" readonly="1"
string="Invoices"/>
</xpath>
</field>

View File

@@ -7,6 +7,18 @@
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="priority">60</field>
<field name="arch" type="xml">
<!-- Transaction count smart button -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_poynt_transactions"
type="object"
class="oe_stat_button"
icon="fa-credit-card"
invisible="poynt_transaction_count == 0">
<field name="poynt_transaction_count" widget="statinfo" string="Poynt Payments"/>
</button>
</xpath>
<!-- Collect Payment action button -->
<xpath expr="//button[@id='create_invoice']" position="after">
<button name="action_poynt_collect_payment"
string="Collect Payment"

View File

@@ -409,6 +409,7 @@ class PoyntPaymentWizard(models.TransientModel):
txn_payload = {
'action': action,
'fundingSourceType': 'CREDIT_DEBIT',
'amounts': {
'transactionAmount': minor_amount,
'orderAmount': minor_amount,

View File

@@ -314,6 +314,7 @@ class PoyntRefundWizard(models.TransientModel):
refund_payload = {
'action': 'REFUND',
'fundingSourceType': 'CREDIT_DEBIT',
'parentId': parent_txn_id,
'fundingSource': {
'type': 'CREDIT_DEBIT',