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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion extension.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
public function up(): void
{
Schema::connection(config('storefront.connection.db'))->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');
});
}
};
178 changes: 150 additions & 28 deletions server/src/Http/Controllers/v1/CheckoutController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -496,7 +498,7 @@ public function createStripeSetupIntentForCustomer(CreateStripeSetupIntentReques
}

$customer = static::resolveCheckoutCustomer($customerId);
if ($customer instanceof \Illuminate\Http\JsonResponse) {
if ($customer instanceof JsonResponse) {
return $customer;
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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 = [
Expand All @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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)
{
Expand Down
14 changes: 14 additions & 0 deletions server/src/Http/Controllers/v1/CustomerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@

class CustomerController extends Controller
{
protected function authenticatedCustomerForRequest(Request $request): ?Contact
{
return Storefront::getCustomerFromToken();
}

/**
* Query for Storefront Customer orders.
*
Expand Down Expand Up @@ -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);
}
Expand All @@ -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']);

Expand Down
Loading
Loading