From 12fd8361ec971e484f799c74974ec85d26e9ad87 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 27 Aug 2026 14:37:46 +0800 Subject: [PATCH 1/9] Bugfix: Patch QPay service callback URL, and ensure callbackUrl is provided --- server/src/Http/Controllers/v1/CheckoutController.php | 2 +- server/src/Support/QPay.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 4ca11f8..963ca03 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -812,7 +812,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa // Create qpay invoice $invoice = null; if ($ebarimtInvoiceCode) { - $invoice = $qpay->createEbarimtInvoice($ebarimtInvoiceCode, $senderInvoiceNo, $invoiceReceiverCode, $invoiceReceiverData, $invoiceDescription, $taxType, $districtCode, $lines); + $invoice = $qpay->createEbarimtInvoice($ebarimtInvoiceCode, $senderInvoiceNo, $invoiceReceiverCode, $invoiceReceiverData, $invoiceDescription, $taxType, $districtCode, $lines, $callbackUrl); } else { $invoice = $qpay->createSimpleInvoice($invoiceAmount, $invoiceCode, $invoiceDescription, $invoiceReceiverCode, $senderInvoiceNo, $callbackUrl); } diff --git a/server/src/Support/QPay.php b/server/src/Support/QPay.php index 36f843a..83531da 100644 --- a/server/src/Support/QPay.php +++ b/server/src/Support/QPay.php @@ -108,7 +108,7 @@ class QPay */ public function __construct(?string $username = null, ?string $password = null, ?string $callbackUrl = null) { - $this->callbackUrl = $callbackUrl ?? Utils::apiUrl('storefront/v1/checkouts/process-qpay'); + $this->callbackUrl = $callbackUrl ?? Utils::apiUrl('storefront/v1/checkouts/capture-qpay'); $this->requestOptions = [ 'base_uri' => $this->buildRequestUrl(), 'auth' => [$username, $password], From 15f11d88a2b0d9f87c06645554e6e4c1ace00f67 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 28 Aug 2026 15:34:44 +0800 Subject: [PATCH 2/9] chore(release): v0.4.20 --- composer.json | 2 +- extension.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index b2543c0..912e020 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/storefront-api", - "version": "0.4.19", + "version": "0.4.20", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "keywords": [ "fleetbase-extension", diff --git a/extension.json b/extension.json index b14d893..e461eed 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Storefront", - "version": "0.4.19", + "version": "0.4.20", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "repository": "https://github.com/fleetbase/storefront", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index 164e3eb..07a3c57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/storefront-engine", - "version": "0.4.19", + "version": "0.4.20", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "fleetbase": { "route": "storefront", From ee672a47a17884a8165f5a372dc4cde70bcd6767 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 28 Aug 2026 18:44:44 +0800 Subject: [PATCH 3/9] refactor: harden QPay callback wiring and trim redundant work Apply review findings from the callback URL fix: - Single-source the capture callback URL in QPay::callbackUrl(), honoring the configurable storefront route prefix instead of hardcoding it in two files, and document the constructor default as a last resort that cannot carry a checkout id. - Set the per-checkout callback once via setCallback() instead of threading it through optional trailing invoice arguments, so every invoice path on the instance uses it and future call sites cannot silently omit it. - Drop the duplicated callback fallback blocks in createSimpleInvoice and createEbarimtInvoice; createQPayInvoice already applies the identical fallback for both. - Remove a stray leading quote from the paymentCancel/paymentRefund callback_url literals. - Cache the QPay access token across requests (keyed by base URI and username, until shortly before expiry) instead of re-authenticating on every checkout and callback. - Batch-fetch cart products once for invoice line building and let the classification/tax-code helpers accept a preloaded product, removing up to two duplicate product queries per cart item. --- .../Controllers/v1/CheckoutController.php | 14 +-- server/src/Support/QPay.php | 88 ++++++++++++------- 2 files changed, 64 insertions(+), 38 deletions(-) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 963ca03..239e9a3 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -765,7 +765,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa if ($testPayment) { $callbackParams['test'] = data_get($checkoutOptions, 'testPayment'); } - $callbackUrl = Utils::apiUrl('storefront/v1/checkouts/capture-qpay', $callbackParams); + $qpay->setCallback(QPay::callbackUrl($callbackParams)); // Create invoice description $taxType = '1'; // Start with VAT required @@ -784,9 +784,11 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa ]); // Create QPay line items - $lines = QPay::createQpayInitialLines($cart, $serviceQuote, $checkoutOptions); + $lines = QPay::createQpayInitialLines($cart, $serviceQuote, $checkoutOptions); + $cartProducts = Product::whereIn('public_id', collect($cart->items)->pluck('product_id')->filter()->unique())->get()->keyBy('public_id'); foreach ($cart->items as $item) { - $classificationCode = QPay::getCartItemClassificationCode($item); + $product = $item->product_id ? $cartProducts->get($item->product_id) : null; + $classificationCode = QPay::getCartItemClassificationCode($item, $product); $isVatExempt = QPay::isTaxFreeClassificationCode($classificationCode); $line = [ @@ -795,7 +797,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa 'line_unit_price' => number_format($item->price, 2, '.', ''), 'note' => $checkout->public_id, 'classification_code' => $classificationCode, - 'tax_product_code' => QPay::getCartItemTaxProductCode($item), + 'tax_product_code' => QPay::getCartItemTaxProductCode($item, $product), 'taxes' => [ [ 'tax_code' => 'VAT', @@ -812,9 +814,9 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa // Create qpay invoice $invoice = null; if ($ebarimtInvoiceCode) { - $invoice = $qpay->createEbarimtInvoice($ebarimtInvoiceCode, $senderInvoiceNo, $invoiceReceiverCode, $invoiceReceiverData, $invoiceDescription, $taxType, $districtCode, $lines, $callbackUrl); + $invoice = $qpay->createEbarimtInvoice($ebarimtInvoiceCode, $senderInvoiceNo, $invoiceReceiverCode, $invoiceReceiverData, $invoiceDescription, $taxType, $districtCode, $lines); } else { - $invoice = $qpay->createSimpleInvoice($invoiceAmount, $invoiceCode, $invoiceDescription, $invoiceReceiverCode, $senderInvoiceNo, $callbackUrl); + $invoice = $qpay->createSimpleInvoice($invoiceAmount, $invoiceCode, $invoiceDescription, $invoiceReceiverCode, $senderInvoiceNo); } // Update checkout with invoice id diff --git a/server/src/Support/QPay.php b/server/src/Support/QPay.php index 83531da..fb6b815 100644 --- a/server/src/Support/QPay.php +++ b/server/src/Support/QPay.php @@ -8,6 +8,7 @@ use Fleetbase\Storefront\Models\Product; use Fleetbase\Support\Utils; use GuzzleHttp\Client; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; /** @@ -96,6 +97,18 @@ class QPay '2119000', ]; + /** + * Build the URL to the storefront QPay capture callback endpoint. + * + * @param array $params Query parameters to append (e.g. the checkout public id) + */ + public static function callbackUrl(array $params = []): string + { + $prefix = trim((string) config('storefront.api.routing.prefix', 'storefront'), '/'); + + return Utils::apiUrl($prefix . '/v1/checkouts/capture-qpay', $params); + } + /** * QPay constructor. * @@ -108,7 +121,10 @@ class QPay */ public function __construct(?string $username = null, ?string $password = null, ?string $callbackUrl = null) { - $this->callbackUrl = $callbackUrl ?? Utils::apiUrl('storefront/v1/checkouts/capture-qpay'); + // Last-resort default only: it reaches the capture endpoint but carries no + // checkout id, so callers must provide a full callback URL via setCallback() + // or the constructor for payments to be captured + $this->callbackUrl = $callbackUrl ?? static::callbackUrl(); $this->requestOptions = [ 'base_uri' => $this->buildRequestUrl(), 'auth' => [$username, $password], @@ -333,15 +349,29 @@ public function setAuthToken(?string $accessToken = null): QPay { if ($accessToken) { $this->useBearerToken($accessToken); - } else { - $response = $this->getAuthToken(); - $token = $response->access_token; - if (isset($token)) { - $this->useBearerToken($token); + return $this; + } + + $username = $this->requestOptions['auth'][0] ?? ''; + $cacheKey = 'storefront:qpay:token:' . md5(($this->requestOptions['base_uri'] ?? '') . '|' . $username); + $token = Cache::get($cacheKey); + + if (!$token) { + $response = $this->getAuthToken(); + $token = data_get($response, 'access_token'); + $expiresIn = (int) data_get($response, 'expires_in', 0); + + // Reuse the token across requests until shortly before it expires + if ($token && $expiresIn > 120) { + Cache::put($cacheKey, $token, $expiresIn - 60); } } + if ($token) { + $this->useBearerToken($token); + } + return $this; } @@ -359,10 +389,6 @@ public function setAuthToken(?string $accessToken = null): QPay */ public function createSimpleInvoice(int $amount, ?string $invoiceCode = '', ?string $invoiceDescription = '', ?string $invoiceReceiverCode = '', ?string $senderInvoiceNo = '', ?string $callbackUrl = null) { - if (!$callbackUrl && $this->hasCallbackUrl()) { - $callbackUrl = $this->callbackUrl; - } - $params = array_filter([ 'invoice_code' => $invoiceCode, 'amount' => $amount, @@ -392,10 +418,6 @@ public function createSimpleInvoice(int $amount, ?string $invoiceCode = '', ?str */ public function createEbarimtInvoice(?string $invoiceCode = '', ?string $senderInvoiceNo = '', ?string $invoiceReceiverCode = '', array $invoiceReceiverData = [], ?string $invoiceDescription = '', ?string $taxType = '1', ?string $districtCode = '', array $lines = [], ?string $callbackUrl = null) { - if (!$callbackUrl && $this->hasCallbackUrl()) { - $callbackUrl = $this->callbackUrl; - } - $params = array_filter([ 'invoice_code' => $invoiceCode, 'sender_invoice_no' => $senderInvoiceNo, @@ -484,7 +506,7 @@ public function getPayment(string $invoiceId) public function paymentCancel(string $invoiceId, $options = []) { $params = [ - 'callback_url' => '"https://qpay.mn/payment/result?payment_id=' . $invoiceId, + 'callback_url' => 'https://qpay.mn/payment/result?payment_id=' . $invoiceId, ]; return $this->delete('payment/cancel', $params, $options); @@ -501,7 +523,7 @@ public function paymentCancel(string $invoiceId, $options = []) public function paymentRefund(string $invoiceId, $options = []) { $params = [ - 'callback_url' => '"https://qpay.mn/payment/result?payment_id=' . $invoiceId, + 'callback_url' => 'https://qpay.mn/payment/result?payment_id=' . $invoiceId, ]; return $this->delete('payment/refund', $params, $options); @@ -788,11 +810,12 @@ public static function isTaxFreeClassificationCode($classificationCode): bool * Attempts to retrieve the classification code from the item's meta data first, * then falls back to the product's meta data, and finally uses a default code. * - * @param object $item The cart item + * @param object $item The cart item + * @param Product|null $product Optional preloaded product to avoid an extra query * * @return string The classification code (7 digits) */ - public static function getCartItemClassificationCode($item): string + public static function getCartItemClassificationCode($item, ?Product $product = null): string { $classificationCode = '6511100'; @@ -810,14 +833,14 @@ public static function getCartItemClassificationCode($item): string } // Try product meta fallback - if ($item->product_id) { + if (!$product && $item->product_id) { $product = Product::where('public_id', $item->product_id)->first(); + } - if ($product && $product->hasMeta('classification_code')) { - $productCode = $product->getMeta('classification_code'); - if (self::isValidClassificationCode($productCode)) { - return $productCode; - } + if ($product && $product->hasMeta('classification_code')) { + $productCode = $product->getMeta('classification_code'); + if (self::isValidClassificationCode($productCode)) { + return $productCode; } } @@ -831,11 +854,12 @@ public static function getCartItemClassificationCode($item): string * Attempts to retrieve the tax product code from the item's meta data first, * then falls back to the product's meta data, and finally uses a default code. * - * @param object $item The cart item + * @param object $item The cart item + * @param Product|null $product Optional preloaded product to avoid an extra query * * @return string The tax_product_code code (7 digits) */ - public static function getCartItemTaxProductCode($item): string + public static function getCartItemTaxProductCode($item, ?Product $product = null): string { $taxProductCode = '319'; @@ -853,14 +877,14 @@ public static function getCartItemTaxProductCode($item): string } // Try product meta fallback - if ($item->product_id) { + if (!$product && $item->product_id) { $product = Product::where('public_id', $item->product_id)->first(); + } - if ($product && $product->hasMeta('tax_product_code')) { - $productCode = $product->getMeta('tax_product_code'); - if (self::isValidTaxProductCode($productCode)) { - return $productCode; - } + if ($product && $product->hasMeta('tax_product_code')) { + $productCode = $product->getMeta('tax_product_code'); + if (self::isValidTaxProductCode($productCode)) { + return $productCode; } } From ebbccc27872d4c755c0c3380d5ed1dd2eaa86183 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 30 Aug 2026 16:05:14 +0800 Subject: [PATCH 4/9] test: cover QPay auth token caching across instances Covers the Cache::put path in setAuthToken (the one statement the QPay callback refactor left unexercised): a token response carrying expires_in is cached, and a second instance with the same credentials reuses it without re-authenticating. Restores the 100% backend coverage baseline. --- server/tests/Unit/Support/QPayTest.php | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/server/tests/Unit/Support/QPayTest.php b/server/tests/Unit/Support/QPayTest.php index d4d15d1..21d90dd 100644 --- a/server/tests/Unit/Support/QPayTest.php +++ b/server/tests/Unit/Support/QPayTest.php @@ -125,6 +125,38 @@ function qpayWithResponses(array $responses, array &$history): QPay ->and((string) $history[2]['request']->getUri())->toContain('payment/payment-7'); }); +test('qpay caches auth tokens across instances until shortly before expiry', function () { + $history = []; + $mock = new MockHandler([ + new Response(200, [], '{"access_token":"cached-token","expires_in":3600}'), + ]); + $handler = HandlerStack::create($mock); + $handler->push(Middleware::history($history)); + + $qpay = new QPay('cache-merchant', 'cache-secret', 'https://storefront.test/qpay'); + $qpay->updateRequestOption('handler', $handler); + + expect($qpay->setAuthToken())->toBe($qpay); + + // A new instance with the same credentials reuses the cached token without re-authenticating + $secondHistory = []; + $secondMock = new MockHandler([ + new Response(200, [], '{"ok":true}'), + ]); + $secondHandler = HandlerStack::create($secondMock); + $secondHandler->push(Middleware::history($secondHistory)); + + $second = new QPay('cache-merchant', 'cache-secret', 'https://storefront.test/qpay'); + $second->updateRequestOption('handler', $secondHandler); + + expect($second->setAuthToken())->toBe($second) + ->and($second->get('health')->ok)->toBeTrue() + ->and($history)->toHaveCount(1) + ->and((string) $history[0]['request']->getUri())->toContain('auth/token') + ->and($secondHistory)->toHaveCount(1) + ->and($secondHistory[0]['request']->getHeaderLine('Authorization'))->toBe('Bearer cached-token'); +}); + test('qpay invoice factory authenticates and forwards invoice parameters', function () { QPayInvoiceStub::$captured = []; From 3b83d54dff79f06af48433e58192a52826b3ef27 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 30 Aug 2026 18:49:45 +0800 Subject: [PATCH 5/9] fix: serialize storefront checkout capture --- .../Controllers/v1/CheckoutController.php | 45 ++++++++++++++++++- .../CheckoutBoundaryContractsTest.php | 39 ++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 239e9a3..3288d38 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -990,8 +990,9 @@ protected function createOrderFromCheckout($checkout, $transactionDetails, $note // Define a unique lock key for this specific checkout $lockKey = 'create-order-checkout-' . $checkout->uuid; - // Attempt to acquire a lock for 10 seconds - $lock = Cache::lock($lockKey, 10); + // Keep the lock for the full provider/order workflow. Checkout creation can + // legitimately take longer than ten seconds when downstream services are slow. + $lock = Cache::lock($lockKey, 120); if ($lock->get()) { try { @@ -1019,6 +1020,7 @@ protected function createOrderFromCheckout($checkout, $transactionDetails, $note 'transactionDetails' => $transactionDetails, 'notes' => $notes, ]); + $captureRequest->attributes->set('storefront_checkout_lock_held', true); // Call captureOrder to create the order $this->captureOrder($captureRequest); @@ -1115,6 +1117,15 @@ private function processCartItem($cartItem, $payload, $customer) public function captureOrder(CaptureOrderRequest $request) { + if (!$request->attributes->get('storefront_checkout_lock_held')) { + $checkout = Checkout::where('token', $request->input('token'))->first(); + if (!$checkout) { + return response()->apiError('Checkout session not found.'); + } + + return $this->captureOrderWithLock($checkout, $request); + } + $token = $request->input('token'); $transactionDetails = $request->input('transactionDetails', []); // optional details to be supplied about transaction $notes = $request->input('notes'); @@ -1406,6 +1417,36 @@ public function captureOrder(CaptureOrderRequest $request) return new OrderResource($order); } + protected function captureOrderWithLock(Checkout $checkout, CaptureOrderRequest $request) + { + $lock = Cache::lock('create-order-checkout-' . $checkout->uuid, 120); + + if (!$lock->get()) { + // Another request owns the capture. Return its authoritative result when it + // has already completed; otherwise tell the caller to retry safely. + $checkout->refresh(); + if ($checkout->order_uuid && $checkout->order) { + return new OrderResource($checkout->order); + } + + return response()->apiError('Order capture is already in progress.', 409); + } + + try { + $checkout->refresh(); + if ($checkout->order_uuid && $checkout->order) { + return new OrderResource($checkout->order); + } + + $request->attributes->set('storefront_checkout_lock_held', true); + + return $this->captureOrder($request); + } finally { + $request->attributes->remove('storefront_checkout_lock_held'); + $lock->release(); + } + } + public function captureMultipleOrders(CaptureOrderRequest $request) { $token = $request->input('token'); diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index 8468256..af15533 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -3605,3 +3605,42 @@ public function get(): bool expect($result)->toBeInstanceOf(Fleetbase\FleetOps\Models\Order::class) ->and($result->uuid)->toBe('concurrent_order_uuid'); }); + +test('direct checkout capture rejects a concurrent request before creating financial records', function () { + createCheckoutBoundarySchema(); + Model::getConnectionResolver()->connection('mysql')->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_public', + 'token' => 'checkout_token', + ]); + $previousCache = app('cache'); + app()->instance('cache', new class { + public function lock($key, $seconds): object + { + expect($key)->toBe('create-order-checkout-checkout_uuid') + ->and($seconds)->toBe(120); + + return new class { + public function get(): bool + { + return false; + } + }; + } + }); + Illuminate\Support\Facades\Facade::clearResolvedInstance('cache'); + $connection = Model::getConnectionResolver()->connection('mysql'); + $transactionCount = $connection->table('transactions')->count(); + $orderCount = $connection->table('orders')->count(); + + $response = (new CheckoutController())->captureOrder( + CaptureOrderRequest::create('/checkout/capture', 'POST', ['token' => 'checkout_token']) + ); + app()->instance('cache', $previousCache); + Illuminate\Support\Facades\Facade::clearResolvedInstance('cache'); + + expect($response->getStatusCode())->toBe(409) + ->and($response->getData(true))->toBe(['error' => 'Order capture is already in progress.']) + ->and($connection->table('transactions')->count())->toBe($transactionCount) + ->and($connection->table('orders')->count())->toBe($orderCount); +}); From 9b47158ca816c3eca4e032124fee0fa436beaf28 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 30 Aug 2026 18:49:45 +0800 Subject: [PATCH 6/9] fix: allow cash pickup without delivery quote --- .../Controllers/v1/CheckoutController.php | 4 +- .../CheckoutBoundaryContractsTest.php | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 239e9a3..c2986a0 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -325,7 +325,7 @@ public function beforeCheckout(InitializeCheckoutRequest $request) return response()->apiError('Unable to initialize checkout!'); } - public static function initializeCashCheckout(Contact $customer, Gateway $gateway, ServiceQuote $serviceQuote, Cart $cart, $checkoutOptions, $request) + public static function initializeCashCheckout(Contact $customer, Gateway $gateway, ?ServiceQuote $serviceQuote, Cart $cart, $checkoutOptions, $request) { // check if pickup order $isPickup = $checkoutOptions->is_pickup; @@ -358,7 +358,7 @@ public static function initializeCashCheckout(Contact $customer, Gateway $gatewa 'network_uuid' => session('storefront_network'), 'cart_uuid' => $cart->uuid, 'gateway_uuid' => $gateway->uuid ?? null, - 'service_quote_uuid' => $serviceQuote->uuid, + 'service_quote_uuid' => $serviceQuote?->uuid, 'owner_uuid' => $customer->uuid, 'owner_type' => 'fleet-ops:contact', 'amount' => $amount, diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index 8468256..18e4703 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -1432,6 +1432,49 @@ public function notify($notification): void ->and($checkout->cart_state['subtotal'])->toBe(1000); }); +test('cash pickup checkout does not require a delivery quote', function () { + createCheckoutBoundarySchema(); + session([ + 'company' => 'company_uuid', + 'storefront_store' => 'store_uuid', + 'storefront_network' => null, + ]); + $cart = new Cart(); + $cart->forceFill([ + 'uuid' => 'cart_uuid', + 'currency' => 'USD', + 'items' => [ + [ + 'id' => 'line_one', + 'quantity' => 1, + 'subtotal' => 1000, + ], + ], + 'events' => [], + ]); + $customer = new Fleetbase\Storefront\Models\Customer(); + $customer->forceFill(['uuid' => 'customer_uuid']); + $gateway = Gateway::cash(); + $gateway->forceFill(['uuid' => 'gateway_uuid']); + + $response = CheckoutController::initializeCashCheckout( + $customer, + $gateway, + null, + $cart, + (object) ['is_pickup' => true], + Request::create('/checkout') + ); + $checkout = Checkout::query()->first(); + + expect($response->getStatusCode())->toBe(200) + ->and($checkout)->not->toBeNull() + ->and($checkout->service_quote_uuid)->toBeNull() + ->and($checkout->amount)->toBe(1000) + ->and($checkout->is_cod)->toBeTrue() + ->and($checkout->is_pickup)->toBeTrue(); +}); + test('cash checkout infers the owning store from a single public store cart item', function () { createCheckoutBoundarySchema(); $connection = Model::getConnectionResolver()->connection('mysql'); From f44f289543085f5657fe36c340407be0e50aabbb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 30 Aug 2026 18:49:45 +0800 Subject: [PATCH 7/9] fix: authorize customer profile updates --- .../Controllers/v1/CustomerController.php | 14 +++++ .../CustomerControllerContractsTest.php | 51 +++++++++++++++++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index d9ef326..e7d742d 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -30,6 +30,11 @@ class CustomerController extends Controller { + protected function authenticatedCustomerForRequest(Request $request): ?Contact + { + return Storefront::getCustomerFromToken(); + } + /** * Query for Storefront Customer orders. * @@ -291,6 +296,11 @@ public function create(CreateCustomerRequest $request) */ public function update($id, UpdateContactRequest $request) { + $authenticatedCustomer = $this->authenticatedCustomerForRequest($request); + if (!$authenticatedCustomer) { + return response()->apiError('Not authorized to update customer.', 403); + } + if (Str::startsWith($id, 'customer')) { $id = Str::replaceFirst('customer', 'contact', $id); } @@ -302,6 +312,10 @@ public function update($id, UpdateContactRequest $request) return response()->apiError('Customer resource not found.'); } + if ($contact->uuid !== $authenticatedCustomer->uuid) { + return response()->apiError('Not authorized to update customer.', 403); + } + // get request input $input = $request->only(['name', 'type', 'title', 'email', 'phone', 'meta']); diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index c9cba78..ca226ea 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -53,6 +53,16 @@ protected function findExistingUserByPhone(string $phone, string $excludedUserUu } } +class AuthenticatedCustomerControllerStub extends CustomerController +{ + public ?Fleetbase\FleetOps\Models\Contact $authenticatedCustomer = null; + + protected function authenticatedCustomerForRequest(Request $request): ?Fleetbase\FleetOps\Models\Contact + { + return $this->authenticatedCustomer; + } +} + function bindUnauthenticatedCustomerRequest(array $input = []): Request { $request = Request::create('/customer', 'POST', $input); @@ -1757,8 +1767,9 @@ public function sendNow($notifiables, $notification) test('customer public id aliases preserve not-found update find and delete contracts', function () { createCustomerControllerContactsSchema(); session(['company' => null]); - $controller = new CustomerController(); - $update = UpdateContactRequest::create('/customer/customer_missing', 'PATCH'); + $controller = new AuthenticatedCustomerControllerStub(); + $controller->authenticatedCustomer = new Fleetbase\FleetOps\Models\Contact(['uuid' => 'authenticated_customer']); + $update = UpdateContactRequest::create('/customer/customer_missing', 'PATCH'); $updated = $controller->update('customer_missing', $update); $found = $controller->find('customer_missing'); @@ -1769,6 +1780,37 @@ public function sendNow($notifiables, $notification) ->and($deleted->getData(true))->toBe(['error' => 'Customer resource not found.']); }); +test('customer update requires the token owner and rejects cross-customer mutation', function () { + createCustomerControllerContactsSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('contacts')->insert([ + [ + 'uuid' => 'owner_uuid', + 'public_id' => 'contact_owner', + 'company_uuid' => 'company_uuid', + 'type' => 'customer', + ], + [ + 'uuid' => 'other_uuid', + 'public_id' => 'contact_other', + 'company_uuid' => 'company_uuid', + 'type' => 'customer', + ], + ]); + session(['company' => 'company_uuid']); + $controller = new AuthenticatedCustomerControllerStub(); + $request = UpdateContactRequest::create('/customer/contact_other', 'PUT'); + + $unauthenticated = $controller->update('contact_other', $request); + $controller->authenticatedCustomer = Fleetbase\FleetOps\Models\Contact::where('uuid', 'owner_uuid')->firstOrFail(); + $crossCustomer = $controller->update('contact_other', $request); + + expect($unauthenticated->getStatusCode())->toBe(403) + ->and($unauthenticated->getData(true))->toBe(['error' => 'Not authorized to update customer.']) + ->and($crossCustomer->getStatusCode())->toBe(403) + ->and($crossCustomer->getData(true))->toBe(['error' => 'Not authorized to update customer.']); +}); + test('customer update find and delete persist profile location and photo removal contracts', function () { $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); @@ -1837,8 +1879,9 @@ public function sendNow($notifiables, $notification) 'public_id' => 'file_abcdefgh', ]); session(['company' => 'company_uuid']); - $controller = new CustomerController(); - $request = UpdateContactRequest::create('/customer/contact_public', 'PATCH', [ + $controller = new AuthenticatedCustomerControllerStub(); + $controller->authenticatedCustomer = Fleetbase\FleetOps\Models\Contact::where('uuid', 'contact_uuid')->firstOrFail(); + $request = UpdateContactRequest::create('/customer/contact_public', 'PATCH', [ 'name' => 'Ada Buyer', 'email' => 'ada@example.test', 'place' => 'place_public', From d5f3aa2e7dfa3e6fc288e2a6f2c12d14ced06a3f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 30 Aug 2026 19:49:42 +0800 Subject: [PATCH 8/9] fix: verify stripe payment before capture --- ...e_payment_intent_id_to_checkouts_table.php | 25 +++ .../Controllers/v1/CheckoutController.php | 117 +++++++++++--- server/src/Models/Checkout.php | 2 +- .../CheckoutBoundaryContractsTest.php | 148 ++++++++++++++++++ 4 files changed, 272 insertions(+), 20 deletions(-) create mode 100644 server/migrations/2026_08_30_000000_add_stripe_payment_intent_id_to_checkouts_table.php diff --git a/server/migrations/2026_08_30_000000_add_stripe_payment_intent_id_to_checkouts_table.php b/server/migrations/2026_08_30_000000_add_stripe_payment_intent_id_to_checkouts_table.php new file mode 100644 index 0000000..ca6b28d --- /dev/null +++ b/server/migrations/2026_08_30_000000_add_stripe_payment_intent_id_to_checkouts_table.php @@ -0,0 +1,25 @@ +table('checkouts', function (Blueprint $table) { + $table->string('stripe_payment_intent_id', 191) + ->nullable() + ->unique() + ->after('gateway_uuid'); + }); + } + + public function down(): void + { + Schema::connection(config('storefront.connection.db'))->table('checkouts', function (Blueprint $table) { + $table->dropUnique(['stripe_payment_intent_id']); + $table->dropColumn('stripe_payment_intent_id'); + }); + } +}; diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 239e9a3..8879330 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -29,6 +29,7 @@ use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Storefront\Support\StripeUtils; use Fleetbase\Support\SocketCluster\SocketClusterService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; @@ -243,7 +244,7 @@ protected function applyFoodTruckOrderData(?FoodTruck $foodTruck, array $orderMe * unaffected: with no token the body parameter is still used, since a guest has no * token to present. * - * @return Customer|\Illuminate\Http\JsonResponse|null + * @return Customer|JsonResponse|null */ protected static function resolveCheckoutCustomer(?string $customerId) { @@ -298,7 +299,7 @@ public function beforeCheckout(InitializeCheckoutRequest $request) } $gateway = Storefront::findGateway($gatewayCode); $customer = static::resolveCheckoutCustomer($customerId); - if ($customer instanceof \Illuminate\Http\JsonResponse) { + if ($customer instanceof JsonResponse) { return $customer; } $serviceQuote = ServiceQuote::select(['amount', 'meta', 'uuid', 'public_id'])->where('public_id', $serviceQuoteId)->first(); @@ -455,19 +456,20 @@ public static function initializeStripeCheckout(Contact $customer, Gateway $gate // create checkout token $checkout = Checkout::create([ - 'company_uuid' => session('company'), - 'store_uuid' => session('storefront_store'), - 'network_uuid' => session('storefront_network'), - 'cart_uuid' => $cart->uuid, - 'gateway_uuid' => $gateway->uuid, - 'service_quote_uuid' => $serviceQuote ? $serviceQuote->uuid : null, - 'owner_uuid' => $customer->uuid, - 'owner_type' => 'fleet-ops:contact', - 'amount' => $amount, - 'currency' => $currency, - 'is_pickup' => $isPickup, - 'options' => $checkoutOptions, - 'cart_state' => $cart->toArray(), + 'company_uuid' => session('company'), + 'store_uuid' => session('storefront_store'), + 'network_uuid' => session('storefront_network'), + 'cart_uuid' => $cart->uuid, + 'gateway_uuid' => $gateway->uuid, + 'service_quote_uuid' => $serviceQuote ? $serviceQuote->uuid : null, + 'owner_uuid' => $customer->uuid, + 'owner_type' => 'fleet-ops:contact', + 'amount' => $amount, + 'currency' => $currency, + 'is_pickup' => $isPickup, + 'options' => $checkoutOptions, + 'cart_state' => $cart->toArray(), + 'stripe_payment_intent_id' => $paymentIntent->id, ]); // See initializeCheckout: `checkout` is the chkt_* public id GET /checkouts/status @@ -496,7 +498,7 @@ public function createStripeSetupIntentForCustomer(CreateStripeSetupIntentReques } $customer = static::resolveCheckoutCustomer($customerId); - if ($customer instanceof \Illuminate\Http\JsonResponse) { + if ($customer instanceof JsonResponse) { return $customer; } @@ -598,7 +600,7 @@ public function updateStripePaymentIntent(Request $request) // @codeCoverageIgnoreEnd $customer = static::resolveCheckoutCustomer($customerId); - if ($customer instanceof \Illuminate\Http\JsonResponse) { + if ($customer instanceof JsonResponse) { return $customer; } if (!$customer) { @@ -851,7 +853,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa * - `respond` (boolean): Whether to return a JSON response. * - `test` (string|null): A test scenario indicator ('success' or 'error') for sandbox mode. * - * @return \Illuminate\Http\JsonResponse a JSON response with payment data or error details + * @return JsonResponse a JSON response with payment data or error details * * @throws \Exception if an error occurs during payment processing, an API error is returned */ @@ -1160,6 +1162,18 @@ public function captureOrder(CaptureOrderRequest $request) $currency = $checkout->currency ?? $cart->getCurrency(); $store = $about; + if ($gateway && $gateway->isStripeGateway) { + $stripeVerification = $this->verifyStripePaymentForCheckout($checkout, $gateway, $customer, (int) $amount, (string) $currency); + if ($stripeVerification instanceof JsonResponse) { + return $stripeVerification; + } + + // Provider data is authoritative. Never allow client-supplied transaction + // identifiers or payment status to replace the verified Stripe values. + $transactionDetails = array_merge($transactionDetails, $stripeVerification); + $request->merge(['transactionDetails' => $transactionDetails]); + } + // check if order is via network for a single store $isNetworkOrder = $about->is_network === true; $isMultiCart = $cart->isMultiCart; @@ -1406,6 +1420,71 @@ public function captureOrder(CaptureOrderRequest $request) return new OrderResource($order); } + protected function verifyStripePaymentForCheckout(Checkout $checkout, Gateway $gateway, ?Contact $customer, int $amount, ?string $currency): array|JsonResponse + { + if (!$checkout->stripe_payment_intent_id) { + return response()->apiError('Stripe PaymentIntent is not linked to this checkout.', 422); + } + + if (!static::hasStripeSecret($gateway)) { + return response()->apiError('Gateway not configured correctly!'); + } + + \Stripe\Stripe::setApiKey($gateway->config->secret_key); + + try { + $paymentIntent = \Stripe\PaymentIntent::retrieve($checkout->stripe_payment_intent_id); + } catch (StripeAuthenticationException $e) { + return static::stripeAuthenticationError($gateway, 'verify_checkout_payment_intent'); + } catch (\Exception $e) { + Log::warning('[Storefront] Unable to verify Stripe checkout payment.', [ + 'checkout_uuid' => $checkout->uuid, + 'gateway_uuid' => $gateway->uuid, + 'exception' => get_class($e), + ]); + + return response()->apiError('Unable to verify Stripe payment.', 502); + } + + if ($paymentIntent->status !== 'succeeded') { + return response()->apiError('Stripe payment has not been completed.', 402); + } + + if (!is_string($currency) || trim($currency) === '') { + return response()->apiError('Stripe payment does not match this checkout.', 422); + } + + $stripeCustomerId = is_object($paymentIntent->customer) ? $paymentIntent->customer->id : $paymentIntent->customer; + $expectedCustomer = $customer?->getMeta('stripe_id'); + $expectedAmount = Utils::formatAmountForStripe($amount, $currency); + $expectedLiveMode = !$gateway->sandbox; + + if ( + $paymentIntent->id !== $checkout->stripe_payment_intent_id + || (int) $paymentIntent->amount !== $expectedAmount + || (int) $paymentIntent->amount_received !== $expectedAmount + || strtolower((string) $paymentIntent->currency) !== strtolower((string) $currency) + || !$expectedCustomer + || $stripeCustomerId !== $expectedCustomer + || (bool) $paymentIntent->livemode !== $expectedLiveMode + ) { + Log::warning('[Storefront] Stripe payment did not match checkout.', [ + 'checkout_uuid' => $checkout->uuid, + 'gateway_uuid' => $gateway->uuid, + 'payment_intent_id' => $paymentIntent->id, + ]); + + return response()->apiError('Stripe payment does not match this checkout.', 422); + } + + return [ + 'id' => $paymentIntent->id, + 'transaction_id' => $paymentIntent->id, + 'payment_intent_id' => $paymentIntent->id, + 'payment_status' => $paymentIntent->status, + ]; + } + public function captureMultipleOrders(CaptureOrderRequest $request) { $token = $request->input('token'); @@ -1749,7 +1828,7 @@ public function afterCheckout(Request $request) * including payment confirmation and order details. If payment is confirmed but * order doesn't exist (callback failed), it will create the order as a fallback. * - * @return \Illuminate\Http\JsonResponse + * @return JsonResponse */ public function getCheckoutStatus(Request $request) { diff --git a/server/src/Models/Checkout.php b/server/src/Models/Checkout.php index 3efa37f..16ef909 100644 --- a/server/src/Models/Checkout.php +++ b/server/src/Models/Checkout.php @@ -44,7 +44,7 @@ class Checkout extends StorefrontModel * * @var array */ - protected $fillable = ['company_uuid', 'order_uuid', 'network_uuid', 'store_uuid', 'cart_uuid', 'gateway_uuid', 'service_quote_uuid', 'owner_uuid', 'owner_type', 'amount', 'currency', 'is_cod', 'is_pickup', 'options', 'token', 'cart_state', 'captured']; + protected $fillable = ['company_uuid', 'order_uuid', 'network_uuid', 'store_uuid', 'cart_uuid', 'gateway_uuid', 'service_quote_uuid', 'owner_uuid', 'owner_type', 'amount', 'currency', 'is_cod', 'is_pickup', 'options', 'token', 'cart_state', 'captured', 'stripe_payment_intent_id']; /** * The attributes that should be cast to native types. diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index 8468256..26ac71f 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -157,6 +157,14 @@ public function foodTruckOrderData(?Fleetbase\Storefront\Models\FoodTruck $foodT } } +class CheckoutStripeVerificationProbe extends CheckoutController +{ + public function verifyStripePayment(Checkout $checkout, Gateway $gateway, ?Contact $customer, int $amount, ?string $currency): array|Illuminate\Http\JsonResponse + { + return $this->verifyStripePaymentForCheckout($checkout, $gateway, $customer, $amount, $currency); + } +} + test('authenticated checkout identity cannot be replaced by a submitted customer id', function () { createCheckoutBoundarySchema(); $connection = Model::getConnectionResolver()->connection('mysql'); @@ -538,6 +546,7 @@ function createCheckoutBoundarySchema(): void $table->text('cart_state')->nullable(); $table->string('token')->nullable(); $table->string('order_uuid')->nullable(); + $table->string('stripe_payment_intent_id')->nullable(); $table->boolean('captured')->default(false); $table->timestamps(); $table->timestamp('deleted_at')->nullable(); @@ -1657,11 +1666,150 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ->and($data['token'])->toBe($checkout->token) ->and($data['checkout'])->toBe($checkout->public_id) ->and($checkout->owner_uuid)->toBe('customer_uuid') + ->and($checkout->stripe_payment_intent_id)->toBe('pi_checkout') ->and($checkout->amount)->toBe(2200) ->and($checkout->is_pickup)->toBeTrue() ->and($createdCustomerResponse->getData(true)['customerId'])->toBe('cus_checkout'); }); +test('stripe capture verifies the server linked payment intent before order creation', function () { + createCheckoutBoundarySchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('stores')->insert([ + 'uuid' => 'store_uuid', + 'public_id' => 'store_public', + 'company_uuid' => 'company_uuid', + 'key' => 'store_key', + 'name' => 'Test store', + 'currency' => 'USD', + ]); + $connection->table('gateways')->insert([ + 'uuid' => 'stripe_gateway_uuid', + 'owner_uuid' => 'store_uuid', + 'code' => 'stripe', + 'type' => 'stripe', + 'sandbox' => true, + 'config' => json_encode(['secret_key' => 'sk_test_storefront']), + ]); + $connection->table('contacts')->insert([ + 'uuid' => 'customer_uuid', + 'public_id' => 'contact_public', + 'company_uuid' => 'company_uuid', + 'type' => 'customer', + 'meta' => json_encode(['stripe_id' => 'cus_checkout']), + ]); + $connection->table('carts')->insert([ + 'uuid' => 'cart_uuid', + 'public_id' => 'cart_public', + 'company_uuid' => 'company_uuid', + 'unique_identifier' => 'stripe-cart', + 'currency' => 'USD', + 'items' => json_encode([ + ['id' => 'line_one', 'quantity' => 1, 'subtotal' => 2500], + ]), + 'events' => '[]', + 'expires_at' => now()->addHour(), + ]); + $connection->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_public', + 'token' => 'checkout_token', + 'cart_uuid' => 'cart_uuid', + 'gateway_uuid' => 'stripe_gateway_uuid', + 'owner_uuid' => 'customer_uuid', + 'owner_type' => Contact::class, + 'stripe_payment_intent_id' => 'pi_checkout', + 'amount' => 2500, + 'currency' => 'USD', + 'is_pickup' => true, + 'options' => json_encode(['is_pickup' => true]), + ]); + session([ + 'company' => 'company_uuid', + 'storefront_key' => 'store_key', + 'storefront_store' => 'store_uuid', + ]); + $http = new class implements Stripe\HttpClient\ClientInterface { + public string $scenario = 'pending'; + + public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null) + { + expect($absUrl)->toContain('/payment_intents/pi_checkout'); + + if ($this->scenario === 'provider_failure') { + return [json_encode(['error' => ['message' => 'Provider unavailable', 'type' => 'api_error']]), 500, []]; + } + if ($this->scenario === 'authentication_failure') { + return [json_encode(['error' => ['message' => 'sk_secret_should_never_leak', 'type' => 'authentication_error']]), 401, []]; + } + + return [json_encode([ + 'id' => $this->scenario === 'wrong_intent' ? 'pi_other' : 'pi_checkout', + 'object' => 'payment_intent', + 'status' => $this->scenario === 'pending' ? 'requires_payment_method' : 'succeeded', + 'amount' => $this->scenario === 'wrong_amount' ? 2600 : 2500, + 'amount_received' => $this->scenario === 'wrong_received' ? 2400 : 2500, + 'currency' => $this->scenario === 'wrong_currency' ? 'eur' : 'usd', + 'customer' => $this->scenario === 'wrong_customer' ? 'cus_other' : 'cus_checkout', + 'livemode' => $this->scenario === 'wrong_mode', + ]), 200, []]; + } + }; + Stripe\ApiRequestor::setHttpClient($http); + $controller = new CheckoutController(); + $request = fn () => CaptureOrderRequest::create('/checkout/capture', 'POST', [ + 'token' => 'checkout_token', + 'transactionDetails' => ['transaction_id' => 'untrusted_client_id'], + ]); + + $pending = $controller->captureOrder($request()); + $http->scenario = 'wrong_amount'; + $mismatched = $controller->captureOrder($request()); + $http->scenario = 'provider_failure'; + $providerError = $controller->captureOrder($request()); + $checkout = Checkout::where('uuid', 'checkout_uuid')->firstOrFail(); + $gateway = Gateway::where('uuid', 'stripe_gateway_uuid')->firstOrFail(); + $customer = Contact::where('uuid', 'customer_uuid')->firstOrFail(); + $http->scenario = 'succeeded'; + $details = (new CheckoutStripeVerificationProbe())->verifyStripePayment($checkout, $gateway, $customer, 2500, 'USD'); + $mismatchResponses = []; + foreach (['wrong_intent', 'wrong_received', 'wrong_currency', 'wrong_customer', 'wrong_mode'] as $scenario) { + $http->scenario = $scenario; + $mismatchResponses[] = (new CheckoutStripeVerificationProbe())->verifyStripePayment($checkout, $gateway, $customer, 2500, 'USD'); + } + $http->scenario = 'authentication_failure'; + $authenticationError = (new CheckoutStripeVerificationProbe())->verifyStripePayment($checkout, $gateway, $customer, 2500, 'USD'); + $unlinkedCheckout = clone $checkout; + $unlinkedCheckout->stripe_payment_intent_id = null; + $unlinkedError = (new CheckoutStripeVerificationProbe())->verifyStripePayment($unlinkedCheckout, $gateway, $customer, 2500, 'USD'); + Stripe\ApiRequestor::setHttpClient(new Stripe\HttpClient\CurlClient()); + session(['storefront_key' => null, 'storefront_store' => null]); + + expect($pending->getStatusCode())->toBe(402) + ->and($pending->getData(true))->toBe(['error' => 'Stripe payment has not been completed.']) + ->and($mismatched->getStatusCode())->toBe(422) + ->and($mismatched->getData(true))->toBe(['error' => 'Stripe payment does not match this checkout.']) + ->and($providerError->getStatusCode())->toBe(502) + ->and($providerError->getData(true))->toBe(['error' => 'Unable to verify Stripe payment.']) + ->and($details)->toBe([ + 'id' => 'pi_checkout', + 'transaction_id' => 'pi_checkout', + 'payment_intent_id' => 'pi_checkout', + 'payment_status' => 'succeeded', + ]) + ->and($authenticationError->getData(true))->toBe([ + 'error' => 'Stripe gateway authentication failed. Verify the configured secret key.', + ]) + ->and(json_encode($authenticationError->getData(true)))->not->toContain('sk_secret_should_never_leak') + ->and($unlinkedError->getStatusCode())->toBe(422) + ->and($unlinkedError->getData(true))->toBe(['error' => 'Stripe PaymentIntent is not linked to this checkout.']); + + foreach ($mismatchResponses as $mismatchResponse) { + expect($mismatchResponse->getStatusCode())->toBe(422) + ->and($mismatchResponse->getData(true))->toBe(['error' => 'Stripe payment does not match this checkout.']); + } +}); + test('stripe checkout retries missing customers and contains ephemeral-key and intent failures', function () { createCheckoutBoundarySchema(); $connection = Model::getConnectionResolver()->connection('mysql'); From 863944b462b31ad47135a52ba40eb21bd9f7f88d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 31 Aug 2026 10:52:37 +0800 Subject: [PATCH 9/9] test: cover checkout capture and stripe verification edge paths Restores the 100% backend coverage baseline after the atomic-capture, customer-authorization, and stripe-verification fixes landed: - Locked capture re-entry: unknown token rejection and reuse of an already-completed order. - Stripe verification guards: gateway without a configured secret key and a blank currency on an otherwise-succeeded payment intent. - The verified provider details merge: a succeeded intent's id/status overwrite client-supplied transactionDetails before multi-store capture. - Lock-denied capture returning the order the winning request created. - The real authenticatedCustomerForRequest seam resolving (null) from a request without a Customer-Token header. --- .../CheckoutBoundaryContractsTest.php | 260 ++++++++++++++++++ .../CustomerControllerContractsTest.php | 14 + 2 files changed, 274 insertions(+) diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index ec2d4f0..9113ec3 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -165,6 +165,18 @@ public function verifyStripePayment(Checkout $checkout, Gateway $gateway, ?Conta } } +class CheckoutMultiStoreCaptureProbe extends CheckoutController +{ + public ?CaptureOrderRequest $forwardedRequest = null; + + public function captureMultipleOrders(CaptureOrderRequest $request) + { + $this->forwardedRequest = $request; + + return response()->json(['captured' => 'multiple']); + } +} + test('authenticated checkout identity cannot be replaced by a submitted customer id', function () { createCheckoutBoundarySchema(); $connection = Model::getConnectionResolver()->connection('mysql'); @@ -3835,3 +3847,251 @@ public function get(): bool ->and($connection->table('transactions')->count())->toBe($transactionCount) ->and($connection->table('orders')->count())->toBe($orderCount); }); + +test('locked checkout capture re-validates its session and reuses a completed order', function () { + createCheckoutBoundarySchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('stores')->insert([ + 'uuid' => 'store_uuid', + 'public_id' => 'store_public', + 'company_uuid' => 'company_uuid', + 'key' => 'store_key', + 'name' => 'Test store', + 'currency' => 'USD', + ]); + $connection->table('orders')->insert([ + 'uuid' => 'completed_order_uuid', + 'public_id' => 'order_completed', + ]); + $connection->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_public', + 'token' => 'checkout_token', + 'order_uuid' => 'completed_order_uuid', + ]); + session([ + 'company' => 'company_uuid', + 'storefront_key' => 'store_key', + 'storefront_store' => 'store_uuid', + ]); + $controller = new CheckoutController(); + $lockHeld = function (string $token) { + $request = CaptureOrderRequest::create('/checkout/capture', 'POST', ['token' => $token]); + $request->attributes->set('storefront_checkout_lock_held', true); + + return $request; + }; + + $missing = $controller->captureOrder($lockHeld('missing_token')); + $completed = $controller->captureOrder($lockHeld('checkout_token')); + session(['storefront_key' => null, 'storefront_store' => null]); + + expect($missing->getData(true))->toBe(['error' => 'Checkout session not found.']) + ->and($completed)->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Order::class) + ->and($completed->resource->uuid)->toBe('completed_order_uuid'); +}); + +test('stripe verification rejects unconfigured gateways and blank currencies', function () { + createCheckoutBoundarySchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('gateways')->insert([ + 'uuid' => 'stripe_gateway_uuid', + 'owner_uuid' => 'store_uuid', + 'code' => 'stripe', + 'type' => 'stripe', + 'sandbox' => true, + 'config' => json_encode(['secret_key' => 'sk_test_storefront']), + ]); + $connection->table('contacts')->insert([ + 'uuid' => 'customer_uuid', + 'public_id' => 'contact_public', + 'company_uuid' => 'company_uuid', + 'type' => 'customer', + 'meta' => json_encode(['stripe_id' => 'cus_checkout']), + ]); + $connection->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_public', + 'token' => 'checkout_token', + 'stripe_payment_intent_id' => 'pi_checkout', + 'amount' => 2500, + 'currency' => 'USD', + ]); + $checkout = Checkout::where('uuid', 'checkout_uuid')->firstOrFail(); + $customer = Contact::where('uuid', 'customer_uuid')->firstOrFail(); + $unconfigured = new Gateway(); + $unconfigured->forceFill([ + 'uuid' => 'unconfigured_gateway_uuid', + 'type' => 'stripe', + 'config' => [], + ]); + $probe = new CheckoutStripeVerificationProbe(); + $missingSecret = $probe->verifyStripePayment($checkout, $unconfigured, $customer, 2500, 'USD'); + + $http = new class implements Stripe\HttpClient\ClientInterface { + public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null) + { + return [json_encode([ + 'id' => 'pi_checkout', + 'object' => 'payment_intent', + 'status' => 'succeeded', + 'amount' => 2500, + 'amount_received' => 2500, + 'currency' => 'usd', + 'customer' => 'cus_checkout', + 'livemode' => false, + ]), 200, []]; + } + }; + Stripe\ApiRequestor::setHttpClient($http); + $gateway = Gateway::where('uuid', 'stripe_gateway_uuid')->firstOrFail(); + $blankCurrency = $probe->verifyStripePayment($checkout, $gateway, $customer, 2500, ' '); + Stripe\ApiRequestor::setHttpClient(new Stripe\HttpClient\CurlClient()); + + expect($missingSecret->getData(true))->toBe(['error' => 'Gateway not configured correctly!']) + ->and($blankCurrency->getStatusCode())->toBe(422) + ->and($blankCurrency->getData(true))->toBe(['error' => 'Stripe payment does not match this checkout.']); +}); + +test('stripe capture forwards verified provider details into multi store capture', function () { + createCheckoutBoundarySchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('networks')->insert([ + 'uuid' => 'network_uuid', + 'public_id' => 'network_public', + 'key' => 'network_test_key', + 'name' => 'Test network', + 'currency' => 'USD', + 'options' => json_encode(['multi_cart_enabled' => true]), + ]); + $connection->table('gateways')->insert([ + 'uuid' => 'stripe_gateway_uuid', + 'owner_uuid' => 'network_uuid', + 'code' => 'stripe', + 'type' => 'stripe', + 'sandbox' => true, + 'config' => json_encode(['secret_key' => 'sk_test_storefront']), + ]); + $connection->table('contacts')->insert([ + 'uuid' => 'customer_uuid', + 'public_id' => 'contact_public', + 'company_uuid' => 'company_uuid', + 'type' => 'customer', + 'meta' => json_encode(['stripe_id' => 'cus_checkout']), + ]); + $connection->table('carts')->insert([ + 'uuid' => 'cart_uuid', + 'public_id' => 'cart_public', + 'company_uuid' => 'company_uuid', + 'unique_identifier' => 'multi-store-cart', + 'currency' => 'USD', + 'items' => json_encode([ + ['id' => 'line_one', 'store_id' => 'store_one', 'quantity' => 1, 'subtotal' => 1000], + ['id' => 'line_two', 'store_id' => 'store_two', 'quantity' => 1, 'subtotal' => 1500], + ]), + 'events' => '[]', + 'expires_at' => now()->addHour(), + ]); + $connection->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_public', + 'token' => 'checkout_token', + 'cart_uuid' => 'cart_uuid', + 'gateway_uuid' => 'stripe_gateway_uuid', + 'owner_uuid' => 'customer_uuid', + 'owner_type' => Contact::class, + 'stripe_payment_intent_id' => 'pi_checkout', + 'amount' => 2500, + 'currency' => 'USD', + 'is_pickup' => true, + 'options' => json_encode(['is_pickup' => true]), + ]); + session([ + 'company' => 'company_uuid', + 'storefront_key' => 'network_test_key', + 'storefront_network' => 'network_uuid', + 'storefront_store' => null, + ]); + $http = new class implements Stripe\HttpClient\ClientInterface { + public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1', $maxNetworkRetries = null) + { + return [json_encode([ + 'id' => 'pi_checkout', + 'object' => 'payment_intent', + 'status' => 'succeeded', + 'amount' => 2500, + 'amount_received' => 2500, + 'currency' => 'usd', + 'customer' => 'cus_checkout', + 'livemode' => false, + ]), 200, []]; + } + }; + Stripe\ApiRequestor::setHttpClient($http); + $controller = new CheckoutMultiStoreCaptureProbe(); + + $response = $controller->captureOrder(CaptureOrderRequest::create('/checkout/capture', 'POST', [ + 'token' => 'checkout_token', + 'transactionDetails' => ['transaction_id' => 'untrusted_client_id'], + ])); + Stripe\ApiRequestor::setHttpClient(new Stripe\HttpClient\CurlClient()); + session(['storefront_key' => null, 'storefront_network' => null]); + + expect($response->getData(true))->toBe(['captured' => 'multiple']) + ->and($controller->forwardedRequest)->not->toBeNull() + ->and($controller->forwardedRequest->input('transactionDetails'))->toBe([ + 'transaction_id' => 'pi_checkout', + 'id' => 'pi_checkout', + 'payment_intent_id' => 'pi_checkout', + 'payment_status' => 'succeeded', + ]); +}); + +test('waiting checkout capture returns the order completed by the lock owner', function () { + createCheckoutBoundarySchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('orders')->insert([ + 'uuid' => 'winner_order_uuid', + 'public_id' => 'order_winner', + ]); + $connection->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_public', + 'token' => 'checkout_token', + ]); + $previousCache = app('cache'); + app()->instance('cache', new class($connection) { + public function __construct(private $connection) + { + } + + public function lock($key, $seconds): object + { + return new class($this->connection) { + public function __construct(private $connection) + { + } + + public function get(): bool + { + $this->connection->table('checkouts')->where('uuid', 'checkout_uuid')->update([ + 'order_uuid' => 'winner_order_uuid', + 'captured' => true, + ]); + + return false; + } + }; + } + }); + Illuminate\Support\Facades\Facade::clearResolvedInstance('cache'); + + $response = (new CheckoutController())->captureOrder( + CaptureOrderRequest::create('/checkout/capture', 'POST', ['token' => 'checkout_token']) + ); + app()->instance('cache', $previousCache); + Illuminate\Support\Facades\Facade::clearResolvedInstance('cache'); + + expect($response)->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Order::class) + ->and($response->resource->uuid)->toBe('winner_order_uuid'); +}); diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index ca226ea..4fa4ff9 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -9,6 +9,14 @@ use Illuminate\Session\ArraySessionHandler; use Illuminate\Session\Store as SessionStore; +class BaseAuthenticatedCustomerProbe extends CustomerController +{ + public function baseAuthenticatedCustomer(Request $request): ?Fleetbase\FleetOps\Models\Contact + { + return parent::authenticatedCustomerForRequest($request); + } +} + class SocialCustomerControllerStub extends CustomerController { public bool $appleValid = true; @@ -2002,3 +2010,9 @@ public function sendNow($notifiables, $notification) expect($resource->resource)->toHaveCount(1) ->and($resource->resource->first()->uuid)->toBe('customer_one'); }); + +test('customer authentication seam resolves the token customer from the storefront request', function () { + $probe = new BaseAuthenticatedCustomerProbe(); + + expect($probe->baseAuthenticatedCustomer(Request::create('/customer/contact_public', 'PUT')))->toBeNull(); +});