Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion api/paymentmethods/creditcard/creditcard.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ public function __construct()
// @codingStandardsIgnoreStart
public function pay($customVars = [])
{
$this->payload['name'] = $this->issuer;
$issuer = is_string($this->issuer) ? trim($this->issuer) : '';
if ($issuer === '' || $issuer === '0') {
throw new Exception('Please select a credit or debit card before continuing with payment.');
}

$this->payload['name'] = $issuer;

return parent::pay();
}
Expand Down
25 changes: 6 additions & 19 deletions buckaroo3.php
Original file line number Diff line number Diff line change
Expand Up @@ -1066,11 +1066,12 @@ private static function resolveValidOrderStateId(array $candidateIds, $fallbackI
/**
* Determine whether an order should be treated as a backorder.
*
* The previous implementation only checked for negative stock values,
* which could miss partial backorders. The new logic considers:
* - global stock management setting
* - ordered vs. in‑stock quantities per order line
* - advanced stock via StockAvailable when present
* Uses stock quantities captured on the order lines at order creation time
* (`product_quantity_in_stock` vs `product_quantity`). Current live stock
* must not be consulted: PrestaShop already decrements stock during
* validateOrder, so comparing live stock to ordered qty falsely marks
* normal paid orders as backorders and triggers a second stock mutation
* via PS_OS_OUTOFSTOCK_PAID.
*
* @param int|null $orderId
*
Expand Down Expand Up @@ -1100,26 +1101,12 @@ private static function isOrderBackOrder($orderId)

foreach ($orderDetails as $detail) {
$orderedQty = (int) $detail['product_quantity'];

// Quantity that was in stock when the order was placed
$inStockAtOrder = (int) $detail['product_quantity_in_stock'];

// If there wasn't enough stock at order time, this line is (at least partly) backordered
if ($inStockAtOrder < $orderedQty) {
return true;
}

// As an additional safety net, check current stock when available
if (class_exists('StockAvailable')) {
$currentQty = (int) StockAvailable::getQuantityAvailableByProduct(
(int) $detail['product_id'],
(int) $detail['product_attribute_id']
);

if ($currentQty < 0 || $currentQty < $orderedQty) {
return true;
}
}
}

return false;
Expand Down
31 changes: 31 additions & 0 deletions controllers/front/request.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ public function postProcess()
return;
}

// Validate credit-card issuer before creating an order. Empty brand/service
// names produce Buckaroo "' is not a valid service name" and left orphan orders.
if (!$this->isValidCreditCardIssuer($payment_method)) {
return;
}

$total = (float)$cart->getOrderTotal(true, Cart::BOTH);
$total = $this->applyBuckarooFee($payment_method, $total);

Expand Down Expand Up @@ -256,6 +262,31 @@ private function isValidPaymentMethod($payment_method): bool
return true;
}

private function isValidCreditCardIssuer(string $payment_method): bool
{
if ($payment_method !== 'creditcard') {
return true;
}

require_once _PS_MODULE_DIR_ . 'buckaroo3/library/checkout/creditcardcheckout.php';

$issuer = CreditCardCheckout::resolveIssuer();
if ($issuer === '') {
$this->logger->logError('Credit card payment started without a selected card brand/issuer.');
$this->redirectToCheckoutStep(
3,
$this->module->l('Please select a credit or debit card before continuing with payment.')
);

return false;
}

// Keep a single POST field so checkout/pay always see the same issuer.
$_POST['BPE_CreditCard'] = $issuer;

return true;
}

private function isValidService()
{
if (Tools::getValue('service') && Tools::getValue('service') != 'digi' && Tools::getValue('service') != 'sepa') {
Expand Down
40 changes: 39 additions & 1 deletion library/checkout/creditcardcheckout.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,48 @@ class CreditCardCheckout extends Checkout
{
protected $customVars = [];

/**
* Resolve the selected credit-card brand/issuer from the request.
*
* Third-party checkouts may omit nested form fields or drop query-string
* parameters, so both POST field names used by this module are checked.
*/
public static function resolveIssuer(): string
{
$candidates = [
Tools::getValue('BPE_CreditCard'),
Tools::getValue('cardCode'),
];

foreach ($candidates as $candidate) {
$issuer = self::normalizeIssuer($candidate);
if ($issuer !== '') {
return $issuer;
}
}

return '';
}

private static function normalizeIssuer($value): string
{
if (!is_string($value) && !is_numeric($value)) {
return '';
}

$issuer = trim((string) $value);

if ($issuer === '' || $issuer === '0') {
return '';
}

return Tools::strtolower($issuer);
}

final public function setCheckout()
{
parent::setCheckout();
$this->payment_request->issuer = Tools::getValue('BPE_CreditCard', Tools::getValue('cardCode'));
$this->payment_request->issuer = self::resolveIssuer();
}

public function startPayment()
Expand Down
16 changes: 14 additions & 2 deletions src/Service/BuckarooPaymentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,20 @@ private function getIndividualCard($method, $details, $cardCode, $configArray)
->setAction($this->context->link->getModuleLink('buckaroo3', 'request', ['method' => $method, 'cardCode' => $cardCode]))
->setModuleName($method);


$newOption->setInputs($this->buckarooFeeService->getBuckarooFeeInputs($method));
// Include cardCode as a POST input so third-party checkouts (e.g. TheCheckout)
// still receive the brand when query-string parameters are stripped.
$inputs = $this->buckarooFeeService->getBuckarooFeeInputs($method);
$inputs[] = [
'type' => 'hidden',
'name' => 'cardCode',
'value' => $cardCode,
];
$inputs[] = [
'type' => 'hidden',
'name' => 'BPE_CreditCard',
'value' => $cardCode,
];
$newOption->setInputs($inputs);

$logoPath = '/modules/buckaroo3/views/img/buckaroo/' . $this->getCardLogoPath($cardData['icon'] ?? null, $details);

Expand Down
48 changes: 47 additions & 1 deletion tests/Unit/Service/BuckarooCreditcardFlowTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,51 @@ public function getOrderTotal($withTaxes, $type)
$this->assertSame('creditcard', $options[0]->getModuleName());
$this->assertStringContainsString('Credit Card', $options[0]->getCallToActionText());
}
}

public function testCreditcardSeparateFlowIncludesCardCodeInputs(): void
{
$config = [
'creditcard' => [
'min_order_amount' => 0,
'max_order_amount' => 0,
'display_in_checkout' => 'separate',
'frontend_label' => 'Credit Card',
'activeCreditcards' => [
['service_code' => 'visa'],
['service_code' => 'mastercard'],
],
],
];

$service = $this->createFlowServiceForMethod('creditcard', $config);

// Avoid depending on RawCreditCardsRepository DB content in unit tests.
$reflection = new \ReflectionClass($service);
$method = $reflection->getMethod('getIndividualCard');
$method->setAccessible(true);

$details = new class {
public function getLabel(): string
{
return 'Credit Card';
}

public function getIcon(): string
{
return 'creditcard.svg';
}
};

$option = $method->invoke($service, 'creditcard', $details, 'visa', $config['creditcard']);
$inputs = $option->getInputs();

$inputMap = [];
foreach ($inputs as $input) {
$inputMap[$input['name']] = $input['value'];
}

$this->assertSame('visa', $inputMap['cardCode'] ?? null);
$this->assertSame('visa', $inputMap['BPE_CreditCard'] ?? null);
$this->assertSame('creditcard', $option->getModuleName());
}
}
57 changes: 56 additions & 1 deletion views/js/buckaroo.js
Original file line number Diff line number Diff line change
Expand Up @@ -296,9 +296,57 @@ function buckaroo() {
});

$('#payment-confirmation button').on('click', (e) => {
ensureCreditCardIssuerInPaymentForm();
methodValidator.init(e);
});

/**
* Copy the selected card brand onto the payment form that will be posted.
* Some checkouts keep issuer controls outside that form.
*/
function ensureCreditCardIssuerInPaymentForm() {
const $selectedOption = $('input[name="payment-option"]:checked');
if (!$selectedOption.length) {
return;
}

const optionId = $selectedOption.attr('id');
const $paymentFormContainer = $('#pay-with-' + optionId + '-form');
const $paymentForm = $paymentFormContainer.find('form').first();
if (!$paymentForm.length) {
return;
}

let issuer = $paymentForm.find('input[name="BPE_CreditCard"]:checked').val()
|| $paymentForm.find('select[name="BPE_CreditCard"]').val()
|| $paymentForm.find('input[name="cardCode"]').val()
|| '';

if (!issuer || issuer === '0') {
const $info = $('#' + optionId + '-additional-information');
issuer = $info.find('input[name="BPE_CreditCard"]:checked').val()
|| $info.find('select[name="BPE_CreditCard"]').val()
|| '';
}

if (!issuer || issuer === '0') {
return;
}

let $hidden = $paymentForm.find('input[type="hidden"][name="BPE_CreditCard"]');
if (!$hidden.length) {
$hidden = $('<input>', { type: 'hidden', name: 'BPE_CreditCard' }).appendTo($paymentForm);
}
$hidden.val(issuer);
}

$(document).on('submit', 'form', function () {
const action = ($(this).attr('action') || '').toLowerCase();
if (action.indexOf('buckaroo3') !== -1 && action.indexOf('method=creditcard') !== -1) {
ensureCreditCardIssuerInPaymentForm();
}
});

const $selectedOption = $('input[name="payment-option"]:checked');
if ($selectedOption.length) {
setTimeout(() => {
Expand All @@ -312,7 +360,14 @@ function buckaroo() {
valid: true,
setMethod: (id) => {
methodValidator.formPointer = $('#pay-with-' + id + '-form form');
methodValidator.methodSelector = methodValidator.formPointer.attr('action').split('method=')[1];
if (!methodValidator.formPointer.length) {
methodValidator.formPointer = $('#pay-with-' + id + '-form');
}
const action = methodValidator.formPointer.attr('action') || '';
const methodMatch = action.match(/[?&]method=([^&]+)/i);
methodValidator.methodSelector = methodMatch
? decodeURIComponent(methodMatch[1]).toLowerCase()
: null;
}, requiredAll: () => {
methodValidator.formPointer.find('label.required').parent().nextAll().find('input').not('.buckaroo-validation-message').each(function () {
let invalid = !validateRequired($(this).val());
Expand Down
15 changes: 6 additions & 9 deletions views/templates/hook/payment_creditcard.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,19 @@
<input type="hidden" name="buckarooKey" value="creditcard">
<form id="booCreditCardForm" action="{$link->getModuleLink('buckaroo3', 'request', ['method' => 'creditcard'])|escape:'quotes':'UTF-8'}" method="post">
<div id="booCreditCardErr" class="booBlAnimError">
{l s='Please choose your bank.' mod='buckaroo3'}"
{l s='Please choose your bank.' mod='buckaroo3'}
</div>
<fieldset>
{if $creditCardDisplayMode === 'dropdown'}
<p class="form-row form-row-wide">
<select name="BPE_CreditCard" id="buckaroo-method-issuer" class="form-control creditcard_banks creditcard_dropdown" >
<select name="BPE_CreditCard" id="buckaroo-method-issuer" class="form-control creditcard_banks creditcard_dropdown">
<option value="0" style="color: grey !important">
<p> {l s='Select your bank' mod='buckaroo3'}</p>
{l s='Select your bank' mod='buckaroo3'}
</option>
{foreach $creditcardIssuers as $key => $issuer}
<div>
<option value="{$key|escape:'html':'UTF-8'}"
id="bankMethod{$key|escape:'html':'UTF-8'}">
{l s=$issuer['name'] mod='buckaroo3'}
</option>
</div>
<option value="{$key|escape:'html':'UTF-8'}" id="bankMethod{$key|escape:'html':'UTF-8'}">
{l s=$issuer['name'] mod='buckaroo3'}
</option>
{/foreach}
</select>
</p>
Expand Down
Loading