diff --git a/fusion_poynt/controllers/main.py b/fusion_poynt/controllers/main.py
index 38c31b3f..fcb640a0 100644
--- a/fusion_poynt/controllers/main.py
+++ b/fusion_poynt/controllers/main.py
@@ -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,
)
diff --git a/fusion_poynt/models/account_move.py b/fusion_poynt/models/account_move.py
index 4d8ed74f..9f0333a5 100644
--- a/fusion_poynt/models/account_move.py
+++ b/fusion_poynt/models/account_move.py
@@ -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()
diff --git a/fusion_poynt/models/payment_transaction.py b/fusion_poynt/models/payment_transaction.py
index 6c57d01f..52dbd30c 100644
--- a/fusion_poynt/models/payment_transaction.py
+++ b/fusion_poynt/models/payment_transaction.py
@@ -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.
diff --git a/fusion_poynt/models/sale_order.py b/fusion_poynt/models/sale_order.py
index 4f7b2860..fda86651 100644
--- a/fusion_poynt/models/sale_order.py
+++ b/fusion_poynt/models/sale_order.py
@@ -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.
diff --git a/fusion_poynt/static/src/interactions/payment_form.js b/fusion_poynt/static/src/interactions/payment_form.js
index 1701fb26..fb113ed5 100644
--- a/fusion_poynt/static/src/interactions/payment_form.js
+++ b/fusion_poynt/static/src/interactions/payment_form.js
@@ -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."),
diff --git a/fusion_poynt/utils.py b/fusion_poynt/utils.py
index 9c65e1bd..542cba8f 100644
--- a/fusion_poynt/utils.py
+++ b/fusion_poynt/utils.py
@@ -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,
diff --git a/fusion_poynt/views/account_move_views.xml b/fusion_poynt/views/account_move_views.xml
index 95f22aff..837cbb0d 100644
--- a/fusion_poynt/views/account_move_views.xml
+++ b/fusion_poynt/views/account_move_views.xml
@@ -8,8 +8,15 @@
60
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -117,6 +162,17 @@
+
+
+
+
+
+ Loading...
+
+
Processing your payment...
+
Please do not close this page.
+
+
diff --git a/fusion_poynt/views/payment_transaction_views.xml b/fusion_poynt/views/payment_transaction_views.xml
index 4c363b03..62e4c777 100644
--- a/fusion_poynt/views/payment_transaction_views.xml
+++ b/fusion_poynt/views/payment_transaction_views.xml
@@ -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)."/>
-
+
+
+
+
+
diff --git a/fusion_poynt/views/sale_order_views.xml b/fusion_poynt/views/sale_order_views.xml
index f72b1607..8256447a 100644
--- a/fusion_poynt/views/sale_order_views.xml
+++ b/fusion_poynt/views/sale_order_views.xml
@@ -7,6 +7,18 @@
60
+
+
+
+
+
+