From 89fa8fe36c849723de6c7084e1a709468a7b7246 Mon Sep 17 00:00:00 2001 From: "a.baraliu" Date: Thu, 13 Aug 2026 15:50:26 +0200 Subject: [PATCH] Ensure credit card issuer is correctly handled in payment flow --- api/paymentmethods/creditcard/creditcard.php | 7 ++- buckaroo3.php | 25 ++------ controllers/front/request.php | 31 ++++++++++ library/checkout/creditcardcheckout.php | 40 ++++++++++++- src/Service/BuckarooPaymentService.php | 16 +++++- .../Service/BuckarooCreditcardFlowTest.php | 48 +++++++++++++++- views/js/buckaroo.js | 57 ++++++++++++++++++- views/templates/hook/payment_creditcard.tpl | 15 ++--- 8 files changed, 205 insertions(+), 34 deletions(-) diff --git a/api/paymentmethods/creditcard/creditcard.php b/api/paymentmethods/creditcard/creditcard.php index f75a65ada..c171e3b54 100644 --- a/api/paymentmethods/creditcard/creditcard.php +++ b/api/paymentmethods/creditcard/creditcard.php @@ -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(); } diff --git a/buckaroo3.php b/buckaroo3.php index aebb02e5d..9e5573803 100644 --- a/buckaroo3.php +++ b/buckaroo3.php @@ -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 * @@ -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; diff --git a/controllers/front/request.php b/controllers/front/request.php index 71be11663..a3e565160 100644 --- a/controllers/front/request.php +++ b/controllers/front/request.php @@ -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); @@ -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') { diff --git a/library/checkout/creditcardcheckout.php b/library/checkout/creditcardcheckout.php index 59098234c..d150dfbc8 100644 --- a/library/checkout/creditcardcheckout.php +++ b/library/checkout/creditcardcheckout.php @@ -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() diff --git a/src/Service/BuckarooPaymentService.php b/src/Service/BuckarooPaymentService.php index c395bc1f1..c8e6d8e7d 100644 --- a/src/Service/BuckarooPaymentService.php +++ b/src/Service/BuckarooPaymentService.php @@ -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); diff --git a/tests/Unit/Service/BuckarooCreditcardFlowTest.php b/tests/Unit/Service/BuckarooCreditcardFlowTest.php index 369d326d1..c3c4cd9f1 100644 --- a/tests/Unit/Service/BuckarooCreditcardFlowTest.php +++ b/tests/Unit/Service/BuckarooCreditcardFlowTest.php @@ -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()); + } +} diff --git a/views/js/buckaroo.js b/views/js/buckaroo.js index f809c76ee..e9042156f 100644 --- a/views/js/buckaroo.js +++ b/views/js/buckaroo.js @@ -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 = $('', { 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(() => { @@ -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()); diff --git a/views/templates/hook/payment_creditcard.tpl b/views/templates/hook/payment_creditcard.tpl index 325a5f523..4154e816b 100644 --- a/views/templates/hook/payment_creditcard.tpl +++ b/views/templates/hook/payment_creditcard.tpl @@ -16,22 +16,19 @@
- {l s='Please choose your bank.' mod='buckaroo3'}" + {l s='Please choose your bank.' mod='buckaroo3'}
{if $creditCardDisplayMode === 'dropdown'}

-

{l s='Select your bank' mod='buckaroo3'}

+ {l s='Select your bank' mod='buckaroo3'} {foreach $creditcardIssuers as $key => $issuer} -
- -
+ {/foreach}