states, and pulls data from the authenticated /api/v1/tenant/packages endpoint.
(resources/js/admin/pages/EventFormPage.tsx, resources/js/admin/api.ts)
- Harden tenant-admin auth flow: prevent PKCE state loss, scope out StrictMode double-processing, add SPA
routes for /event-admin/login and /event-admin/logout, and tighten token/session clearing semantics (resources/js/admin/auth/{context,tokens}.tsx, resources/js/admin/pages/{AuthCallbackPage,LogoutPage}.tsx,
resources/js/admin/router.tsx, routes/web.php)
280 lines
8.5 KiB
PHP
280 lines
8.5 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Checkout;
|
|
|
|
use App\Models\CheckoutSession;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class CheckoutWebhookService
|
|
{
|
|
public function __construct(
|
|
private readonly CheckoutSessionService $sessions,
|
|
private readonly CheckoutAssignmentService $assignment,
|
|
) {
|
|
}
|
|
|
|
public function handleStripeEvent(array $event): bool
|
|
{
|
|
$eventType = $event['type'] ?? null;
|
|
$intent = $event['data']['object'] ?? null;
|
|
|
|
if (! $eventType || ! is_array($intent)) {
|
|
return false;
|
|
}
|
|
|
|
if (! str_starts_with($eventType, 'payment_intent.')) {
|
|
return false;
|
|
}
|
|
|
|
$intentId = $intent['id'] ?? null;
|
|
|
|
if (! $intentId) {
|
|
return false;
|
|
}
|
|
|
|
$session = $this->locateStripeSession($intent);
|
|
|
|
if (! $session) {
|
|
return false;
|
|
}
|
|
|
|
$lock = Cache::lock("checkout:webhook:stripe:{$intentId}", 30);
|
|
|
|
if (! $lock->get()) {
|
|
Log::info('[CheckoutWebhook] Stripe intent lock busy', [
|
|
'intent_id' => $intentId,
|
|
'session_id' => $session->id,
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
$session->forceFill([
|
|
'stripe_payment_intent_id' => $session->stripe_payment_intent_id ?: $intentId,
|
|
'provider' => CheckoutSession::PROVIDER_STRIPE,
|
|
])->save();
|
|
|
|
$metadata = [
|
|
'stripe_last_event' => $eventType,
|
|
'stripe_last_event_id' => $event['id'] ?? null,
|
|
'stripe_intent_status' => $intent['status'] ?? null,
|
|
'stripe_last_update_at' => now()->toIso8601String(),
|
|
];
|
|
|
|
$this->mergeProviderMetadata($session, $metadata);
|
|
|
|
return $this->applyStripeIntent($session, $eventType, $intent);
|
|
} finally {
|
|
$lock->release();
|
|
}
|
|
}
|
|
|
|
public function handlePayPalEvent(array $event): bool
|
|
{
|
|
$eventType = $event['event_type'] ?? null;
|
|
$resource = $event['resource'] ?? [];
|
|
|
|
if (! $eventType || ! is_array($resource)) {
|
|
return false;
|
|
}
|
|
|
|
$orderId = $resource['order_id'] ?? $resource['id'] ?? null;
|
|
|
|
$session = $this->locatePayPalSession($resource, $orderId);
|
|
|
|
if (! $session) {
|
|
return false;
|
|
}
|
|
|
|
$lockKey = "checkout:webhook:paypal:".($orderId ?: $session->id);
|
|
$lock = Cache::lock($lockKey, 30);
|
|
|
|
if (! $lock->get()) {
|
|
Log::info('[CheckoutWebhook] PayPal lock busy', [
|
|
'order_id' => $orderId,
|
|
'session_id' => $session->id,
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
$session->forceFill([
|
|
'paypal_order_id' => $orderId ?: $session->paypal_order_id,
|
|
'provider' => CheckoutSession::PROVIDER_PAYPAL,
|
|
])->save();
|
|
|
|
$metadata = [
|
|
'paypal_last_event' => $eventType,
|
|
'paypal_last_event_id' => $event['id'] ?? null,
|
|
'paypal_last_update_at' => now()->toIso8601String(),
|
|
'paypal_order_id' => $orderId,
|
|
'paypal_capture_id' => $resource['id'] ?? null,
|
|
];
|
|
|
|
$this->mergeProviderMetadata($session, $metadata);
|
|
|
|
return $this->applyPayPalEvent($session, $eventType, $resource);
|
|
} finally {
|
|
$lock->release();
|
|
}
|
|
}
|
|
|
|
protected function applyStripeIntent(CheckoutSession $session, string $eventType, array $intent): bool
|
|
{
|
|
switch ($eventType) {
|
|
case 'payment_intent.processing':
|
|
case 'payment_intent.amount_capturable_updated':
|
|
$this->sessions->markProcessing($session, [
|
|
'stripe_intent_status' => $intent['status'] ?? null,
|
|
]);
|
|
return true;
|
|
|
|
case 'payment_intent.requires_action':
|
|
$reason = $intent['next_action']['type'] ?? 'requires_action';
|
|
$this->sessions->markRequiresCustomerAction($session, $reason);
|
|
return true;
|
|
|
|
case 'payment_intent.payment_failed':
|
|
$failure = $intent['last_payment_error']['message'] ?? 'payment_failed';
|
|
$this->sessions->markFailed($session, $failure);
|
|
return true;
|
|
|
|
case 'payment_intent.succeeded':
|
|
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
|
|
$this->sessions->markProcessing($session, [
|
|
'stripe_intent_status' => $intent['status'] ?? null,
|
|
]);
|
|
|
|
$this->assignment->finalise($session, [
|
|
'source' => 'stripe_webhook',
|
|
'stripe_payment_intent_id' => $intent['id'] ?? null,
|
|
'stripe_charge_id' => $this->extractStripeChargeId($intent),
|
|
]);
|
|
|
|
$this->sessions->markCompleted($session, now());
|
|
}
|
|
|
|
return true;
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
protected function applyPayPalEvent(CheckoutSession $session, string $eventType, array $resource): bool
|
|
{
|
|
switch ($eventType) {
|
|
case 'CHECKOUT.ORDER.APPROVED':
|
|
$this->sessions->markProcessing($session, [
|
|
'paypal_order_status' => $resource['status'] ?? null,
|
|
]);
|
|
return true;
|
|
|
|
case 'PAYMENT.CAPTURE.COMPLETED':
|
|
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
|
|
$this->sessions->markProcessing($session, [
|
|
'paypal_order_status' => $resource['status'] ?? null,
|
|
]);
|
|
|
|
$this->assignment->finalise($session, [
|
|
'source' => 'paypal_webhook',
|
|
'paypal_order_id' => $resource['order_id'] ?? null,
|
|
'paypal_capture_id' => $resource['id'] ?? null,
|
|
]);
|
|
|
|
$this->sessions->markCompleted($session, now());
|
|
}
|
|
|
|
return true;
|
|
|
|
case 'PAYMENT.CAPTURE.DENIED':
|
|
$this->sessions->markFailed($session, 'paypal_capture_denied');
|
|
return true;
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
protected function mergeProviderMetadata(CheckoutSession $session, array $data): void
|
|
{
|
|
$session->provider_metadata = array_merge($session->provider_metadata ?? [], $data);
|
|
$session->save();
|
|
}
|
|
|
|
protected function locateStripeSession(array $intent): ?CheckoutSession
|
|
{
|
|
$intentId = $intent['id'] ?? null;
|
|
|
|
if ($intentId) {
|
|
$session = CheckoutSession::query()
|
|
->where('stripe_payment_intent_id', $intentId)
|
|
->first();
|
|
|
|
if ($session) {
|
|
return $session;
|
|
}
|
|
}
|
|
|
|
$metadata = $intent['metadata'] ?? [];
|
|
$sessionId = $metadata['checkout_session_id'] ?? null;
|
|
|
|
if ($sessionId) {
|
|
return CheckoutSession::find($sessionId);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
protected function locatePayPalSession(array $resource, ?string $orderId): ?CheckoutSession
|
|
{
|
|
if ($orderId) {
|
|
$session = CheckoutSession::query()
|
|
->where('paypal_order_id', $orderId)
|
|
->first();
|
|
|
|
if ($session) {
|
|
return $session;
|
|
}
|
|
}
|
|
|
|
$metadata = $this->extractPayPalMetadata($resource);
|
|
$sessionId = $metadata['checkout_session_id'] ?? null;
|
|
|
|
if ($sessionId) {
|
|
return CheckoutSession::find($sessionId);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
protected function extractPayPalMetadata(array $resource): array
|
|
{
|
|
$customId = $resource['custom_id'] ?? ($resource['purchase_units'][0]['custom_id'] ?? null);
|
|
if ($customId) {
|
|
$decoded = json_decode($customId, true);
|
|
if (is_array($decoded)) {
|
|
return $decoded;
|
|
}
|
|
}
|
|
|
|
$meta = Arr::get($resource, 'supplementary_data.related_ids', []);
|
|
return is_array($meta) ? $meta : [];
|
|
}
|
|
|
|
protected function extractStripeChargeId(array $intent): ?string
|
|
{
|
|
$charges = $intent['charges']['data'] ?? null;
|
|
if (is_array($charges) && count($charges) > 0) {
|
|
return $charges[0]['id'] ?? null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|