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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
],
"require": {
"php": "^8.0",
"fleetbase/core-api": "*",
"fleetbase/core-api": "^1.6.69",
"fleetbase/fleetops-api": "*",
"geocoder-php/google-maps-places-provider": "^1.4",
"laravel-notification-channels/apn": "^5.0",
Expand Down
83 changes: 76 additions & 7 deletions server/src/Http/Controllers/v1/CheckoutController.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
use Fleetbase\Storefront\Promotions\PromotionUnavailableException;
use Fleetbase\Storefront\Support\QPay;
use Fleetbase\Storefront\Support\Storefront;
use Fleetbase\Storefront\Support\StorefrontSocket;
use Fleetbase\Storefront\Support\StripeUtils;
use Fleetbase\Support\SocketCluster\SocketClusterService;
use Illuminate\Http\JsonResponse;
Expand Down Expand Up @@ -385,7 +386,7 @@ public static function initializeCashCheckout(Contact $customer, Gateway $gatewa
// GET /checkouts/status needs BOTH, and only initializeQPayCheckout was returning
// the id — so a cash or card client could never reach its own checkout's status.
// The checkout is discarded instead if one of its promotions ran out meanwhile.
return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([
return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [
'checkout' => $checkout->public_id,
'token' => $checkout->token,
]);
Expand Down Expand Up @@ -486,7 +487,7 @@ public static function initializeStripeCheckout(Contact $customer, Gateway $gate

// See initializeCheckout: `checkout` is the chkt_* public id GET /checkouts/status
// requires alongside the token, and nothing but the QPay path used to return it.
return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([
return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [
'paymentIntent' => $paymentIntent->id,
'clientSecret' => $paymentIntent->client_secret,
'ephemeralKey' => $ephemeralKey->secret,
Expand Down Expand Up @@ -718,7 +719,7 @@ public function updateStripePaymentIntent(Request $request)

// Return JSON response with updated PaymentIntent and ephemeral key. `checkout` is
// the chkt_* public id GET /checkouts/status requires alongside the token.
return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([
return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [
'paymentIntent' => $paymentIntent->id,
'clientSecret' => $paymentIntent->client_secret,
'ephemeralKey' => $ephemeralKey->secret,
Expand Down Expand Up @@ -842,7 +843,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa
// Update checkout with invoice id
$checkout->updateOption('qpay_invoice_id', data_get($invoice, 'invoice_id'));

return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([
return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [
'invoice' => $invoice,
'checkout' => $checkout->public_id,
'token' => $checkout->token,
Expand All @@ -861,7 +862,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa
* response for either success or error scenarios.
* - Initializes a QPay instance with the gateway configuration and sets the authentication token.
* - Retrieves the invoice ID from the checkout options and performs a payment check using QPay's API.
* - Publishes the payment data or error response to the SocketCluster channel.
* - Publishes the checkout status, its order and any error to the checkout's realtime channel.
*
* Depending on the 'respond' flag from the request, the method returns a JSON response
* or completes the processing without returning data.
Expand Down Expand Up @@ -925,7 +926,7 @@ public function captureQPayCallback(Request $request)
];
}

SocketClusterService::publish('checkout.' . $checkout->public_id, $data);
static::publishCheckoutUpdate($checkout, $testScenario === 'success', $data['error']);

return $shouldRespond ? response()->json($data) : response()->json();
}
Expand Down Expand Up @@ -978,7 +979,7 @@ public function captureQPayCallback(Request $request)
'error' => null,
];

SocketClusterService::publish('checkout.' . $checkout->public_id, $data);
static::publishCheckoutUpdate($checkout, true);

return $shouldRespond ? response()->json($data) : response()->json();
}
Expand Down Expand Up @@ -2143,6 +2144,74 @@ protected static function promotionCodesFor(Cart $cart, Request $request): array
return array_values(array_unique(array_filter(array_merge((array) $codes, $cart->getPromotionCodes()), 'is_string')));
}

/**
* The JSON response for an initialized checkout.
*
* When realtime socket authentication is enabled it carries `socket_token`: a
* `checkout` token whose scope is exactly this checkout's channel, so a client
* (a guest included) can listen for its own payment confirmation. The field is
* absent while socket authentication is disabled.
*/
protected static function checkoutResponse(Checkout $checkout, array $data): JsonResponse
{
$socketToken = StorefrontSocket::checkoutToken($checkout);
if ($socketToken) {
$data['socket_token'] = $socketToken;
}

return response()->json($data);
}

/**
* Publishes a checkout's progress on its realtime channel.
*
* The payload is what a storefront client acts on — the checkout, its status, the
* order once one exists (serialized exactly as GET checkouts/status returns it) and
* any error — never the raw gateway payment record. A publish failure is logged and
* swallowed: by now the payment is recorded, and clients still recover the outcome
* through GET checkouts/status.
*
* @return array|null the published payload, or null when publishing failed
*/
protected static function publishCheckoutUpdate(Checkout $checkout, bool $paid, ?array $error = null): ?array
{
try {
// A failed payment carries no order, so a client never completes on an error event.
$order = !$error && $checkout->order_uuid ? Order::where('uuid', $checkout->order_uuid)->first() : null;
$status = 'pending';
if ($error) {
$status = 'failed';
} elseif ($order) {
$status = 'completed';
} elseif ($paid) {
$status = 'paid';
}

$data = [
'checkout' => $checkout->public_id,
'status' => $status,
'order' => $order ? static::checkoutChannelOrder($order) : null,
'error' => $error,
];

SocketClusterService::publish(StorefrontSocket::checkoutChannel($checkout), $data);

return $data;
} catch (\Throwable $e) {
Log::warning('[CHECKOUT SOCKET PUBLISH FAILED]: ' . $e->getMessage(), ['checkout' => $checkout->public_id]);

return null;
}
}

/**
* Serializes a checkout's order for its realtime channel, as GET checkouts/status does.
*/
protected static function checkoutChannelOrder(Order $order): array
{
return json_decode(json_encode(new OrderResource($order)), true);
}

/**
* Reserve a new checkout's promotions, discarding the checkout if one ran out meanwhile.
*/
Expand Down
32 changes: 32 additions & 0 deletions server/src/Http/Controllers/v1/CustomerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
use Fleetbase\Storefront\Http\Resources\Customer;
use Fleetbase\Storefront\Push\StorefrontPushChannel;
use Fleetbase\Storefront\Support\Storefront;
use Fleetbase\Storefront\Support\StorefrontSocket;
use Fleetbase\Support\SocketCluster\SocketToken;
use Fleetbase\Support\Utils;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
Expand Down Expand Up @@ -127,6 +129,36 @@ public function unregisterDevice(Request $request)
]);
}

/**
* Mints a realtime socket token for the signed-in customer.
*
* POST storefront/v1/customers/socket-token — authenticated like every other
* customer endpoint: the storefront key plus a Customer-Token header. The token
* is a `customer` principal scoped to the store or network the key belongs to;
* the socket server only lets it subscribe to channels the customer owns.
* Returns 404 while socket authentication is not configured on this instance.
*/
public function socketToken(Request $request)
{
if (!SocketToken::enabled()) {
return response()->apiError('Not found.', 404);
}

$customer = Storefront::getCustomerFromToken();
if (!$customer) {
return response()->apiError('Not authorized to create a socket token for customer.', 401);
}

// A customer's token is only honoured by the storefront whose company the
// customer belongs to, so a token minted against another company's key is refused.
$storefront = Storefront::about();
if (!$storefront || $storefront->company_uuid !== $customer->company_uuid) {
return response()->apiError('Not authorized to create a socket token for customer.', 401);
}

return response()->json(SocketToken::issue(StorefrontSocket::customerPrincipal($customer, $storefront)));
}

/**
* Newer core-api versions add push metadata columns to user_devices.
*
Expand Down
Loading