diff --git a/composer.json b/composer.json index b2543c06..912e0203 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 b14d8932..e461eed8 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 164e3eb9..07a3c579 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", 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 00000000..ca6b28dc --- /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 4ca11f8a..c4090986 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(); @@ -325,7 +326,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 +359,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, @@ -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) { @@ -765,7 +767,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 +786,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 +799,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', @@ -814,7 +818,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa if ($ebarimtInvoiceCode) { $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 @@ -849,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 */ @@ -988,8 +992,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 { @@ -1017,6 +1022,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); @@ -1113,6 +1119,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'); @@ -1158,6 +1173,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; @@ -1404,6 +1431,101 @@ 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, + ]; + } + + 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'); @@ -1747,7 +1869,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/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index d9ef326f..e7d742d6 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/src/Models/Checkout.php b/server/src/Models/Checkout.php index 3efa37f6..16ef909b 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/src/Support/QPay.php b/server/src/Support/QPay.php index 36f843a2..fb6b815d 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/process-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; } } diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index 8468256b..ec2d4f0f 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(); @@ -1432,6 +1441,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'); @@ -1657,11 +1709,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'); @@ -3605,3 +3796,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); +}); diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index c9cba789..ca226eae 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', diff --git a/server/tests/Unit/Support/QPayTest.php b/server/tests/Unit/Support/QPayTest.php index d4d15d16..21d90dd8 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 = [];