This commit is contained in:
gsinghpal
2026-03-20 11:46:41 -04:00
parent 595dccc17d
commit 92369be6e0
71 changed files with 6588 additions and 8 deletions

View File

@@ -15,12 +15,15 @@
'report/poynt_receipt_report.xml',
'report/poynt_receipt_templates.xml',
'data/poynt_surcharge_product.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',
'views/res_config_settings_views.xml',
'wizard/poynt_payment_wizard_views.xml',
'wizard/poynt_refund_wizard_views.xml',

View File

@@ -1,3 +1,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
from . import portal

View File

@@ -19,6 +19,25 @@ from odoo.addons.fusion_poynt import utils as poynt_utils
_logger = logging.getLogger(__name__)
def _detect_card_brand(card_number):
"""Detect the card brand from the card number using BIN prefixes."""
num = (card_number or '').replace(' ', '')
if len(num) < 2:
return 'other'
if num[:2] in ('34', '37'):
return 'amex'
if num[0] == '4':
return 'visa'
prefix2 = int(num[:2])
if 51 <= prefix2 <= 55:
return 'mastercard'
if len(num) >= 4:
prefix4 = int(num[:4])
if 2221 <= prefix4 <= 2720:
return 'mastercard'
return 'other'
class PoyntController(http.Controller):
_return_url = '/payment/poynt/return'
_webhook_url = '/payment/poynt/webhook'
@@ -344,6 +363,81 @@ class PoyntController(http.Controller):
return request.redirect('/odoo/settings')
# === SURCHARGE HELPER === #
def _apply_portal_surcharge(self, tx_sudo, card_type):
"""Apply credit card surcharge to the linked invoice if enabled.
Detects the card brand from the number if card_type is not provided,
adds a surcharge line to the invoice, and updates the transaction
amount to include the fee.
:param tx_sudo: The sudo payment.transaction record.
:param str card_type: The card brand (visa, mastercard, amex, other).
:return: The surcharge fee amount, or 0 if not applied.
:rtype: float
"""
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_poynt.surcharge_enabled', 'False') != 'True':
return 0.0
if not card_type:
card_type = 'other'
rate_key = {
'visa': 'fusion_poynt.surcharge_visa_rate',
'mastercard': 'fusion_poynt.surcharge_mastercard_rate',
'amex': 'fusion_poynt.surcharge_amex_rate',
'debit': 'fusion_poynt.surcharge_debit_rate',
}.get(card_type, 'fusion_poynt.surcharge_other_rate')
rate = float(ICP.get_param(rate_key, '0') or 0)
if rate <= 0:
return 0.0
invoices = tx_sudo.invoice_ids
if not invoices:
base_amount = tx_sudo.amount
else:
base_amount = sum(invoices.mapped('amount_residual'))
fee_amount = round(base_amount * rate / 100.0, 2)
if fee_amount <= 0:
return 0.0
product_id = int(ICP.get_param('fusion_poynt.surcharge_product_id', '0') or 0)
product = request.env['product.product'].sudo().browse(product_id).exists()
if not product:
product = request.env.ref(
'fusion_poynt.product_cc_processing_fee', raise_if_not_found=False,
)
if not product:
_logger.warning("Surcharge product not configured; skipping surcharge")
return 0.0
for invoice in invoices.sudo():
was_posted = invoice.state == 'posted'
if was_posted:
invoice.button_draft()
description = "Credit Card Processing Fee (%.2f%% surcharge)" % rate
invoice.write({
'invoice_line_ids': [(0, 0, {
'product_id': product.id,
'name': description,
'quantity': 1,
'price_unit': fee_amount,
'tax_ids': [(5, 0, 0)],
})],
})
if was_posted:
invoice.action_post()
new_amount = tx_sudo.amount + fee_amount
tx_sudo.write({'amount': new_amount})
return fee_amount
# === JSON-RPC ROUTES (called from frontend JS) === #
@http.route('/payment/poynt/terminals', type='jsonrpc', auth='public')
@@ -372,7 +466,8 @@ class PoyntController(http.Controller):
@http.route('/payment/poynt/process_card', type='jsonrpc', auth='public')
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, **kwargs):
cvv=None, cardholder_name=None, card_type=None,
**kwargs):
"""Process a card payment through Poynt Cloud API.
The frontend sends card details which are passed to Poynt for
@@ -393,6 +488,11 @@ class PoyntController(http.Controller):
return {'error': 'Transaction not found.'}
try:
if not card_type and card_number:
card_type = _detect_card_brand(card_number)
surcharge_fee = self._apply_portal_surcharge(tx_sudo, card_type)
funding_source = {
'type': 'CREDIT_DEBIT',
'card': {
@@ -469,7 +569,7 @@ class PoyntController(http.Controller):
@http.route('/payment/poynt/send_to_terminal', type='jsonrpc', auth='public')
def poynt_send_to_terminal(self, reference=None, terminal_id=None,
poynt_order_id=None, **kwargs):
poynt_order_id=None, card_type=None, **kwargs):
"""Send a payment request to a Poynt terminal device.
:return: Dict with success status or error message.
@@ -491,6 +591,8 @@ class PoyntController(http.Controller):
return {'error': 'Terminal not found.'}
try:
surcharge_fee = self._apply_portal_surcharge(tx_sudo, card_type or 'other')
result = terminal.action_send_payment_to_terminal(
amount=tx_sudo.amount,
currency=tx_sudo.currency_id,

View File

@@ -0,0 +1,60 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
from odoo.addons.sale.controllers.portal import CustomerPortal
class PoyntCustomerPortal(CustomerPortal):
@http.route()
def portal_order_page(
self,
order_id,
report_type=None,
access_token=None,
message=False,
download=False,
payment_amount=None,
amount_selection=None,
**kw
):
"""Auto-inject payment_amount for confirmed orders with outstanding balance.
For confirmed sale orders (state == 'sale') that haven't been fully
paid, this automatically sets payment_amount to the remaining balance
so that the standard portal "Pay Now" button appears without requiring
a separate payment link URL.
Rental orders are excluded -- their payment flow is managed by
fusion_rental.
"""
if payment_amount is None:
try:
order_sudo = self._document_check_access(
'sale.order', order_id, access_token=access_token,
)
except Exception:
order_sudo = None
if order_sudo:
is_rental = getattr(order_sudo, 'is_rental_order', False)
if (
order_sudo.state == 'sale'
and not is_rental
and order_sudo.amount_total > 0
and order_sudo.amount_paid < order_sudo.amount_total
):
payment_amount = order_sudo.amount_total - order_sudo.amount_paid
return super().portal_order_page(
order_id,
report_type=report_type,
access_token=access_token,
message=message,
download=download,
payment_amount=payment_amount,
amount_selection=amount_selection,
**kw,
)

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="product_cc_processing_fee" model="product.product">
<field name="name">CREDIT CARD PROCESSING FEE</field>
<field name="default_code">POYNT_CC_FEE</field>
<field name="type">service</field>
<field name="list_price">0.0</field>
<field name="sale_ok" eval="False"/>
<field name="purchase_ok" eval="False"/>
<field name="taxes_id" eval="[(5, 0, 0)]"/>
<field name="supplier_taxes_id" eval="[(5, 0, 0)]"/>
<field name="description_sale">Credit card processing surcharge</field>
</record>
</data>
</odoo>

View File

@@ -5,4 +5,5 @@ from . import payment_provider
from . import payment_token
from . import payment_transaction
from . import poynt_terminal
from . import res_config_settings
from . import sale_order

View File

@@ -347,6 +347,21 @@ class PaymentProvider(models.Model):
and payment_method_sudo.support_tokenization
),
}
ICP = self.env['ir.config_parameter'].sudo()
surcharge_enabled = ICP.get_param(
'fusion_poynt.surcharge_enabled', 'False',
) == 'True'
if surcharge_enabled:
inline_form_values['surcharge'] = {
'enabled': True,
'visa': float(ICP.get_param('fusion_poynt.surcharge_visa_rate', '0') or 0),
'mastercard': float(ICP.get_param('fusion_poynt.surcharge_mastercard_rate', '0') or 0),
'amex': float(ICP.get_param('fusion_poynt.surcharge_amex_rate', '0') or 0),
'debit': float(ICP.get_param('fusion_poynt.surcharge_debit_rate', '0') or 0),
'other': float(ICP.get_param('fusion_poynt.surcharge_other_rate', '0') or 0),
}
return json.dumps(inline_form_values)
# === ACTION METHODS === #

View File

@@ -92,13 +92,20 @@ class PaymentTransaction(models.Model):
poynt_data = self._poynt_create_order_and_authorize()
if poynt_data:
status = poynt_data.get('status', 'AUTHORIZED')
payment_data = {
'reference': self.reference,
'poynt_order_id': poynt_data.get('order_id'),
'poynt_transaction_id': poynt_data.get('transaction_id'),
'poynt_status': poynt_data.get('status', 'AUTHORIZED'),
'poynt_status': status,
'funding_source': poynt_data.get('funding_source', {}),
}
if status in ('DECLINED', 'FAILED', 'REFUND_FAILED'):
self._set_error(
_("Payment was %(status)s by the processor.",
status=status.lower())
)
return
self._process('poynt', payment_data)
def _poynt_create_order_and_authorize(self):
@@ -148,6 +155,103 @@ class PaymentTransaction(models.Model):
self._set_error(str(e))
return {}
@staticmethod
def _detect_card_brand_from_details(payment_details):
"""Detect card brand from the payment_details string on a token.
Tokens store details like "VISA ending in 1234" or
"AMERICAN_EXPRESS ending in 5678".
"""
details = (payment_details or '').upper()
if 'AMEX' in details or 'AMERICAN_EXPRESS' in details:
return 'amex'
if 'VISA' in details:
return 'visa'
if 'MASTER' in details:
return 'mastercard'
return 'other'
def _apply_token_surcharge(self):
"""Apply surcharge to the linked invoice for token-based payments.
Checks if surcharge is enabled, detects card brand from the token,
adds a surcharge line to the invoice, and updates the transaction
amount. Skips rental orders (recurring charges should not get
surcharge), invoices with no linked records, or invoices where
surcharge is already applied.
"""
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_poynt.surcharge_enabled', 'False') != 'True':
return
if not self.token_id or not self.invoice_ids:
return
for inv in self.invoice_ids:
sale_orders = inv.mapped('line_ids.sale_line_ids.order_id')
for so in sale_orders:
if getattr(so, 'is_rental_order', False):
if not getattr(so, 'rental_apply_cc_fee', True):
return
card_type = self._detect_card_brand_from_details(
self.token_id.payment_details,
)
rate_key = {
'visa': 'fusion_poynt.surcharge_visa_rate',
'mastercard': 'fusion_poynt.surcharge_mastercard_rate',
'amex': 'fusion_poynt.surcharge_amex_rate',
'debit': 'fusion_poynt.surcharge_debit_rate',
}.get(card_type, 'fusion_poynt.surcharge_other_rate')
rate = float(ICP.get_param(rate_key, '0') or 0)
if rate <= 0:
return
product_id = int(ICP.get_param('fusion_poynt.surcharge_product_id', '0') or 0)
product = self.env['product.product'].sudo().browse(product_id).exists()
if not product:
product = self.env.ref(
'fusion_poynt.product_cc_processing_fee', raise_if_not_found=False,
)
if not product:
_logger.warning("Surcharge product not configured; skipping token surcharge")
return
total_fee = 0.0
for invoice in self.invoice_ids.sudo():
already_has = invoice.invoice_line_ids.filtered(
lambda l: l.product_id.id == product.id
)
if already_has:
continue
fee_amount = round(invoice.amount_residual * rate / 100.0, 2)
if fee_amount <= 0:
continue
was_posted = invoice.state == 'posted'
if was_posted:
invoice.button_draft()
description = "Credit Card Processing Fee (%.2f%% surcharge)" % rate
invoice.write({
'invoice_line_ids': [(0, 0, {
'product_id': product.id,
'name': description,
'quantity': 1,
'price_unit': fee_amount,
'tax_ids': [(5, 0, 0)],
})],
})
if was_posted:
invoice.action_post()
total_fee += fee_amount
if total_fee > 0:
self.amount += total_fee
def _poynt_process_token_payment(self):
"""Process a payment using a stored token (card on file).
@@ -156,6 +260,8 @@ class PaymentTransaction(models.Model):
were created before the JWT migration.
"""
try:
self._apply_token_surcharge()
provider = self._get_provider_sudo()
action = 'AUTHORIZE' if provider.capture_manually else 'SALE'
payment_jwt = self.token_id.poynt_payment_token
@@ -213,13 +319,28 @@ class PaymentTransaction(models.Model):
if order_id:
self.poynt_order_id = order_id
status = txn_result.get('status', '')
payment_data = {
'reference': self.reference,
'poynt_order_id': order_id,
'poynt_transaction_id': transaction_id,
'poynt_status': txn_result.get('status', ''),
'poynt_status': status,
'funding_source': txn_result.get('fundingSource', {}),
}
if status in ('DECLINED', 'FAILED', 'REFUND_FAILED'):
processor = txn_result.get('processorResponse', {})
decline_msg = (
processor.get('statusMessage')
or processor.get('message')
or status.lower()
)
self._set_error(
_("Payment %(status)s: %(reason)s",
status=status.lower(), reason=decline_msg)
)
return
self._process('poynt', payment_data)
except ValidationError as e:
self._set_error(str(e))

View File

@@ -0,0 +1,91 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
poynt_surcharge_enabled = fields.Boolean(
string="Enable Credit Card Surcharge",
config_parameter='fusion_poynt.surcharge_enabled',
)
poynt_surcharge_visa_rate = fields.Float(
string="Visa Rate (%)",
config_parameter='fusion_poynt.surcharge_visa_rate',
default=2.5,
)
poynt_surcharge_mastercard_rate = fields.Float(
string="Mastercard Rate (%)",
config_parameter='fusion_poynt.surcharge_mastercard_rate',
default=2.5,
)
poynt_surcharge_amex_rate = fields.Float(
string="Amex Rate (%)",
config_parameter='fusion_poynt.surcharge_amex_rate',
default=3.5,
)
poynt_surcharge_debit_rate = fields.Float(
string="Debit Rate (%)",
config_parameter='fusion_poynt.surcharge_debit_rate',
default=0.0,
)
poynt_surcharge_other_rate = fields.Float(
string="Other Cards Rate (%)",
config_parameter='fusion_poynt.surcharge_other_rate',
default=2.5,
)
poynt_surcharge_product_id = fields.Many2one(
'product.product',
string="Surcharge Product",
config_parameter='fusion_poynt.surcharge_product_id',
help="The service product used for the credit card processing fee line.",
)
@api.model
def get_values(self):
res = super().get_values()
ICP = self.env['ir.config_parameter'].sudo()
product_id = int(ICP.get_param('fusion_poynt.surcharge_product_id', '0') or 0)
if product_id and self.env['product.product'].sudo().browse(product_id).exists():
res['poynt_surcharge_product_id'] = product_id
else:
default = self.env.ref('fusion_poynt.product_cc_processing_fee', raise_if_not_found=False)
res['poynt_surcharge_product_id'] = default.id if default else False
return res
def set_values(self):
super().set_values()
ICP = self.env['ir.config_parameter'].sudo()
ICP.set_param(
'fusion_poynt.surcharge_product_id',
str(self.poynt_surcharge_product_id.id) if self.poynt_surcharge_product_id else '0',
)
def action_open_poynt_provider(self):
provider = self.env['payment.provider'].sudo().search(
[('code', '=', 'poynt')], limit=1,
)
if provider:
return {
'type': 'ir.actions.act_window',
'res_model': 'payment.provider',
'res_id': provider.id,
'view_mode': 'form',
'target': 'current',
}
return {
'type': 'ir.actions.act_window',
'res_model': 'payment.provider',
'view_mode': 'list,form',
'target': 'current',
'domain': [('code', '=', 'poynt')],
}
def action_open_poynt_terminals(self):
return {
'type': 'ir.actions.act_window',
'res_model': 'poynt.terminal',
'view_mode': 'list,form',
'target': 'current',
}

View File

@@ -11,6 +11,8 @@ patch(PaymentForm.prototype, {
setup() {
super.setup();
this.poyntFormData = {};
this._detectedCardType = 'other';
this._selectedCardType = 'other';
},
// #=== DOM MANIPULATION ===#
@@ -42,6 +44,77 @@ patch(PaymentForm.prototype, {
this._setupCardFormatting(poyntContainer);
this._setupTerminalToggle(poyntContainer);
this._setupSurcharge(poyntContainer);
},
_detectCardBrand(number) {
const num = (number || '').replace(/\D/g, '');
if (num.length < 2) return 'other';
const prefix2 = num.substring(0, 2);
if (prefix2 === '34' || prefix2 === '37') return 'amex';
if (num[0] === '4') return 'visa';
const p2 = parseInt(prefix2, 10);
if (p2 >= 51 && p2 <= 55) return 'mastercard';
if (num.length >= 4) {
const p4 = parseInt(num.substring(0, 4), 10);
if (p4 >= 2221 && p4 <= 2720) return 'mastercard';
}
return 'other';
},
_setupSurcharge(container) {
const surchargeConfig = this.poyntFormData.surcharge;
if (!surchargeConfig || !surchargeConfig.enabled) return;
const cardTypeSection = container.querySelector('.o_poynt_card_type_section');
const surchargeNotice = container.querySelector('.o_poynt_surcharge_notice');
if (cardTypeSection) {
cardTypeSection.style.display = 'block';
}
const cardTypeRadios = container.querySelectorAll('input[name="poynt_card_type"]');
cardTypeRadios.forEach(radio => {
radio.addEventListener('change', () => {
this._selectedCardType = radio.value;
this._updateSurchargeDisplay(container);
});
});
this._updateSurchargeDisplay(container);
},
_updateSurchargeDisplay(container) {
const surchargeConfig = this.poyntFormData.surcharge;
if (!surchargeConfig || !surchargeConfig.enabled) return;
const cardType = this._detectedCardType !== 'other'
? this._detectedCardType
: this._selectedCardType;
const rate = surchargeConfig[cardType] || surchargeConfig['other'] || 0;
const amount = this.poyntFormData.minor_amount || 0;
const currencyName = this.poyntFormData.currency_name || 'CAD';
const baseAmount = amount / 100;
const feeAmount = Math.round(baseAmount * rate) / 100;
const rateEl = container.querySelector('#poynt_surcharge_rate');
const amountEl = container.querySelector('#poynt_surcharge_amount');
const noticeEl = container.querySelector('.o_poynt_surcharge_notice');
if (rateEl) rateEl.textContent = rate.toFixed(2);
if (amountEl) amountEl.textContent = `$${feeAmount.toFixed(2)}`;
if (noticeEl) {
noticeEl.style.display = rate > 0 ? 'block' : 'none';
}
const radioToCheck = container.querySelector(
`input[name="poynt_card_type"][value="${cardType}"]`
);
if (radioToCheck && !radioToCheck.checked) {
radioToCheck.checked = true;
}
},
_setupCardFormatting(container) {
@@ -57,6 +130,17 @@ patch(PaymentForm.prototype, {
formatted += value[i];
}
e.target.value = formatted;
const detected = this._detectCardBrand(value);
if (detected !== this._detectedCardType) {
this._detectedCardType = detected;
if (detected !== 'other') {
this._selectedCardType = detected;
}
this._updateSurchargeDisplay(
e.target.closest('.o_poynt_payment_form')
);
}
});
}
@@ -228,11 +312,19 @@ patch(PaymentForm.prototype, {
}
},
_getSelectedCardType(inlineForm) {
const checked = inlineForm.querySelector('input[name="poynt_card_type"]:checked');
return checked ? checked.value : 'other';
},
async _processCardPayment(processingValues, inlineForm) {
const cardNumber = inlineForm.querySelector('#poynt_card_number').value.replace(/\D/g, '');
const expiry = inlineForm.querySelector('#poynt_expiry').value;
const cvv = inlineForm.querySelector('#poynt_cvv').value;
const cardholder = inlineForm.querySelector('#poynt_cardholder').value;
const cardType = this._detectedCardType !== 'other'
? this._detectedCardType
: this._getSelectedCardType(inlineForm);
const [expMonth, expYear] = expiry.split('/').map(Number);
@@ -245,6 +337,7 @@ patch(PaymentForm.prototype, {
exp_year: 2000 + expYear,
cvv: cvv,
cardholder_name: cardholder,
card_type: cardType,
});
if (result.error) {
@@ -268,12 +361,14 @@ patch(PaymentForm.prototype, {
async _processTerminalPayment(processingValues, inlineForm) {
const terminalId = inlineForm.querySelector('#poynt_terminal_select').value;
const cardType = this._getSelectedCardType(inlineForm);
try {
const result = await rpc('/payment/poynt/send_to_terminal', {
reference: processingValues.reference,
terminal_id: parseInt(terminalId),
poynt_order_id: processingValues.poynt_order_id,
card_type: cardType,
});
if (result.error) {

View File

@@ -62,6 +62,45 @@
autocomplete="cc-name"/>
</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>
<div class="d-flex gap-2 flex-wrap">
<div class="form-check">
<input type="radio" class="form-check-input" name="poynt_card_type"
id="poynt_ct_visa" value="visa"/>
<label class="form-check-label" for="poynt_ct_visa">Visa</label>
</div>
<div class="form-check">
<input type="radio" class="form-check-input" name="poynt_card_type"
id="poynt_ct_mc" value="mastercard"/>
<label class="form-check-label" for="poynt_ct_mc">Mastercard</label>
</div>
<div class="form-check">
<input type="radio" class="form-check-input" name="poynt_card_type"
id="poynt_ct_amex" value="amex"/>
<label class="form-check-label" for="poynt_ct_amex">Amex</label>
</div>
<div class="form-check">
<input type="radio" class="form-check-input" name="poynt_card_type"
id="poynt_ct_other" value="other" checked="checked"/>
<label class="form-check-label" for="poynt_ct_other">Other</label>
</div>
</div>
</div>
<!-- Surcharge notice (shown when surcharge is enabled) -->
<div class="mb-3 o_poynt_surcharge_notice" style="display:none;">
<div class="alert alert-info py-2 mb-0">
<small>
<i class="fa fa-info-circle me-1"/>
<span>A credit card processing fee of </span>
<strong id="poynt_surcharge_rate">0.00</strong>
<span>% (<strong id="poynt_surcharge_amount">$0.00</strong>) will be added to your total.</span>
</small>
</div>
</div>
<!-- Terminal payment option -->
<div class="mb-3 o_poynt_terminal_section" style="display:none;">
<hr/>

View File

@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_fusion_poynt" model="ir.ui.view">
<field name="name">res.config.settings.view.form.fusion.poynt</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Fusion Poynt" string="Fusion Poynt" name="fusion_poynt"
groups="fusion_poynt.group_fusion_poynt_admin">
<h2>Credit Card Surcharge</h2>
<div class="row mt-4 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="poynt_surcharge_enabled"/>
</div>
<div class="o_setting_right_pane">
<span class="o_form_label">Credit Card Processing Fee</span>
<div class="text-muted">
Automatically add a surcharge line to invoices when collecting payment
via Poynt. The fee is calculated as a percentage of the invoice total.
</div>
</div>
</div>
</div>
<div class="row mt-4 o_settings_container"
invisible="not poynt_surcharge_enabled">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Surcharge Rates by Card Type</span>
<div class="text-muted mb-2">
Configure the processing fee percentage for each card brand.
The surcharge is added as a separate invoice line before payment.
</div>
<div class="mt-2">
<div class="row mb-2">
<label for="poynt_surcharge_visa_rate"
class="col-5 col-form-label">Visa</label>
<div class="col-4">
<field name="poynt_surcharge_visa_rate" class="o_input"/>
</div>
<div class="col-1 col-form-label">%</div>
</div>
<div class="row mb-2">
<label for="poynt_surcharge_mastercard_rate"
class="col-5 col-form-label">Mastercard</label>
<div class="col-4">
<field name="poynt_surcharge_mastercard_rate" class="o_input"/>
</div>
<div class="col-1 col-form-label">%</div>
</div>
<div class="row mb-2">
<label for="poynt_surcharge_amex_rate"
class="col-5 col-form-label">American Express</label>
<div class="col-4">
<field name="poynt_surcharge_amex_rate" class="o_input"/>
</div>
<div class="col-1 col-form-label">%</div>
</div>
<div class="row mb-2">
<label for="poynt_surcharge_debit_rate"
class="col-5 col-form-label">Debit</label>
<div class="col-4">
<field name="poynt_surcharge_debit_rate" class="o_input"/>
</div>
<div class="col-1 col-form-label">%</div>
</div>
<div class="row mb-2">
<label for="poynt_surcharge_other_rate"
class="col-5 col-form-label">Other Cards</label>
<div class="col-4">
<field name="poynt_surcharge_other_rate" class="o_input"/>
</div>
<div class="col-1 col-form-label">%</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Surcharge Product</span>
<div class="text-muted mb-2">
The service product used for the processing fee invoice line.
Must be a service product with no taxes applied.
</div>
<div class="mt-2">
<field name="poynt_surcharge_product_id"
domain="[('type', '=', 'service')]"/>
</div>
</div>
</div>
</div>
<h2>Quick Links</h2>
<div class="row mt-4 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Payment Provider</span>
<div class="text-muted mb-2">
Configure your Poynt API credentials, business ID,
and default terminal.
</div>
<div class="mt-2">
<button name="action_open_poynt_provider"
type="object"
string="Configure Payment Provider"
class="btn-link"
icon="fa-arrow-right"/>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Terminals</span>
<div class="text-muted mb-2">
View and manage your Poynt terminal devices.
</div>
<div class="mt-2">
<button name="action_open_poynt_terminals"
type="object"
string="Manage Terminals"
class="btn-link"
icon="fa-arrow-right"/>
</div>
</div>
</div>
</div>
</app>
</xpath>
</field>
</record>
<record id="action_fusion_poynt_settings" model="ir.actions.act_window">
<field name="name">Fusion Poynt Settings</field>
<field name="res_model">res.config.settings</field>
<field name="view_mode">form</field>
<field name="target">current</field>
<field name="context" eval="{'module': 'fusion_poynt'}"/>
</record>
</odoo>

View File

@@ -58,6 +58,37 @@ class PoyntPaymentWizard(models.TransientModel):
default='terminal',
)
# --- Card type & surcharge fields ---
card_type = fields.Selection(
selection=[
('visa', "Visa"),
('mastercard', "Mastercard"),
('amex', "American Express"),
('debit', "Debit"),
('other', "Other"),
],
string="Card Type",
)
surcharge_enabled = fields.Boolean(
compute='_compute_surcharge_enabled',
)
surcharge_rate = fields.Float(
string="Surcharge Rate (%)",
digits=(5, 2),
readonly=True,
)
surcharge_amount = fields.Monetary(
string="Surcharge Amount",
currency_field='currency_id',
readonly=True,
)
surcharge_applied = fields.Boolean(default=False)
original_amount = fields.Monetary(
string="Invoice Amount",
currency_field='currency_id',
readonly=True,
)
# --- Terminal fields ---
terminal_id = fields.Many2one(
'poynt.terminal',
@@ -102,6 +133,58 @@ class PoyntPaymentWizard(models.TransientModel):
readonly=True,
)
@api.depends_context('uid')
def _compute_surcharge_enabled(self):
enabled = self.env['ir.config_parameter'].sudo().get_param(
'fusion_poynt.surcharge_enabled', 'False',
) == 'True'
for rec in self:
rec.surcharge_enabled = enabled
@staticmethod
def _detect_card_brand(card_number):
num = (card_number or '').replace(' ', '')
if len(num) < 2:
return 'other'
if num[:2] in ('34', '37'):
return 'amex'
if num[0] == '4':
return 'visa'
prefix2 = int(num[:2])
if 51 <= prefix2 <= 55:
return 'mastercard'
if len(num) >= 4:
prefix4 = int(num[:4])
if 2221 <= prefix4 <= 2720:
return 'mastercard'
return 'other'
def _get_surcharge_rate(self, card_type):
ICP = self.env['ir.config_parameter'].sudo()
rate_key = {
'visa': 'fusion_poynt.surcharge_visa_rate',
'mastercard': 'fusion_poynt.surcharge_mastercard_rate',
'amex': 'fusion_poynt.surcharge_amex_rate',
'debit': 'fusion_poynt.surcharge_debit_rate',
}.get(card_type, 'fusion_poynt.surcharge_other_rate')
return float(ICP.get_param(rate_key, '0') or 0)
@api.onchange('card_number')
def _onchange_card_number(self):
if self.payment_mode == 'card' and self.card_number:
self.card_type = self._detect_card_brand(self.card_number)
@api.onchange('card_type')
def _onchange_card_type(self):
if not self.card_type or not self.surcharge_enabled:
self.surcharge_rate = 0.0
self.surcharge_amount = 0.0
return
rate = self._get_surcharge_rate(self.card_type)
base_amount = self.original_amount or self.amount
self.surcharge_rate = rate
self.surcharge_amount = round(base_amount * rate / 100.0, 2)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
@@ -112,6 +195,7 @@ class PoyntPaymentWizard(models.TransientModel):
invoice = self.env['account.move'].browse(invoice_id)
res['invoice_id'] = invoice.id
res['amount'] = invoice.amount_residual
res['original_amount'] = invoice.amount_residual
res['currency_id'] = invoice.currency_id.id
provider = self.env['payment.provider'].sudo().search([
@@ -135,10 +219,103 @@ class PoyntPaymentWizard(models.TransientModel):
if provider.poynt_default_terminal_id:
self.terminal_id = provider.poynt_default_terminal_id
def _apply_surcharge_if_needed(self):
"""Add the surcharge invoice line if surcharge is enabled and not yet applied."""
if self.surcharge_applied or not self.surcharge_enabled:
return
if not self.card_type:
raise UserError(_("Please select the card type to calculate the surcharge."))
rate = self._get_surcharge_rate(self.card_type)
if rate <= 0:
return
base_amount = self.original_amount or self.amount
fee_amount = round(base_amount * rate / 100.0, 2)
if fee_amount <= 0:
return
ICP = self.env['ir.config_parameter'].sudo()
product_id = int(ICP.get_param('fusion_poynt.surcharge_product_id', '0') or 0)
product = self.env['product.product'].sudo().browse(product_id).exists()
if not product:
product = self.env.ref('fusion_poynt.product_cc_processing_fee', raise_if_not_found=False)
if not product:
raise UserError(
_("Surcharge product not configured. "
"Go to Settings > Fusion Poynt to set it up.")
)
invoice = self.invoice_id.sudo()
was_posted = invoice.state == 'posted'
if was_posted:
invoice.button_draft()
description = _("Credit Card Processing Fee (%(rate).2f%% surcharge)", rate=rate)
invoice.write({
'invoice_line_ids': [(0, 0, {
'product_id': product.id,
'name': description,
'quantity': 1,
'price_unit': fee_amount,
'tax_ids': [(5, 0, 0)],
})],
})
if was_posted:
invoice.action_post()
self.write({
'surcharge_applied': True,
'surcharge_rate': rate,
'surcharge_amount': fee_amount,
'amount': invoice.amount_residual,
})
def _remove_surcharge_line(self):
"""Remove the surcharge line from the invoice if it was applied."""
if not self.surcharge_applied:
return
ICP = self.env['ir.config_parameter'].sudo()
product_id = int(ICP.get_param('fusion_poynt.surcharge_product_id', '0') or 0)
product = self.env['product.product'].sudo().browse(product_id).exists()
if not product:
product = self.env.ref('fusion_poynt.product_cc_processing_fee', raise_if_not_found=False)
if not product:
return
invoice = self.invoice_id.sudo()
surcharge_lines = invoice.invoice_line_ids.filtered(
lambda l: l.product_id.id == product.id
)
if not surcharge_lines:
self.surcharge_applied = False
return
was_posted = invoice.state == 'posted'
if was_posted:
invoice.button_draft()
surcharge_lines.unlink()
if was_posted:
invoice.action_post()
self.write({
'surcharge_applied': False,
'surcharge_amount': 0.0,
'surcharge_rate': 0.0,
'amount': invoice.amount_residual,
})
def action_collect_payment(self):
"""Dispatch to the appropriate payment method."""
self.ensure_one()
self._apply_surcharge_if_needed()
if self.amount <= 0:
raise UserError(_("Payment amount must be greater than zero."))
@@ -187,6 +364,7 @@ class PoyntPaymentWizard(models.TransientModel):
except (ValidationError, UserError) as e:
self._cleanup_draft_transaction()
self._remove_surcharge_line()
self.write({
'state': 'error',
'status_message': str(e),
@@ -273,6 +451,31 @@ class PoyntPaymentWizard(models.TransientModel):
'poynt_status': status,
'funding_source': result.get('fundingSource', {}),
}
if status in ('DECLINED', 'FAILED', 'REFUND_FAILED'):
tx._set_error(
_("Payment was %(status)s by the processor.",
status=status.lower())
)
self._cleanup_draft_transaction()
self._remove_surcharge_line()
processor = result.get('processorResponse', {})
decline_msg = (
processor.get('statusMessage')
or processor.get('message')
or status.lower()
)
self.write({
'state': 'error',
'status_message': _(
"Payment %(status)s: %(reason)s",
status=status.lower(),
reason=decline_msg,
),
'poynt_transaction_ref': transaction_id,
})
return self._reopen_wizard()
tx._process('poynt', payment_data)
self.write({
@@ -288,6 +491,7 @@ class PoyntPaymentWizard(models.TransientModel):
except (ValidationError, UserError) as e:
self._cleanup_draft_transaction()
self._remove_surcharge_line()
self.write({
'state': 'error',
'status_message': str(e),
@@ -352,6 +556,7 @@ class PoyntPaymentWizard(models.TransientModel):
if status in ('DECLINED', 'VOIDED', 'REFUNDED'):
self._cleanup_draft_transaction()
self._remove_surcharge_line()
self.write({
'state': 'error',
'status_message': _(
@@ -473,6 +678,7 @@ class PoyntPaymentWizard(models.TransientModel):
"""Cancel the payment and clean up the draft transaction."""
self.ensure_one()
self._cleanup_draft_transaction()
self._remove_surcharge_line()
return {'type': 'ir.actions.act_window_close'}
def _cleanup_draft_transaction(self):

View File

@@ -9,6 +9,9 @@
<field name="state" invisible="1"/>
<field name="poynt_transaction_ref" invisible="1"/>
<field name="provider_id" invisible="1"/>
<field name="surcharge_enabled" invisible="1"/>
<field name="surcharge_applied" invisible="1"/>
<field name="original_amount" invisible="1"/>
<!-- Status banner for waiting / done / error -->
<div class="alert alert-info" role="alert"
@@ -44,6 +47,25 @@
</group>
</group>
<!-- Card Type & Surcharge section -->
<group string="Card Type &amp; Surcharge"
invisible="state == 'done' or not surcharge_enabled">
<group>
<field name="card_type"
widget="radio"
required="surcharge_enabled and state in ('draft', 'error')"
readonly="state not in ('draft', 'error')"/>
</group>
<group invisible="not card_type">
<field name="surcharge_rate" string="Rate (%)"/>
<field name="surcharge_amount"/>
<div class="text-muted" colspan="2"
invisible="surcharge_amount == 0">
A surcharge line will be added to the invoice before payment.
</div>
</group>
</group>
<!-- Terminal section -->
<group string="Terminal"
invisible="payment_mode != 'terminal' or state == 'done'">