$event */ public function handle(array $event): bool { $eventType = $event['event_type'] ?? null; $resource = $event['resource'] ?? null; if (! is_string($eventType) || ! is_array($resource)) { return false; } $normalized = strtoupper($eventType); if (! in_array($normalized, [ 'PAYMENT.CAPTURE.COMPLETED', 'PAYMENT.CAPTURE.PENDING', 'PAYMENT.CAPTURE.DENIED', 'PAYMENT.CAPTURE.REFUNDED', 'CHECKOUT.ORDER.COMPLETED', 'CHECKOUT.ORDER.VOIDED', ], true)) { return false; } $orderId = $this->resolveOrderId($normalized, $resource); if (! $orderId) { return false; } $addon = EventPackageAddon::query() ->where('checkout_id', $orderId) ->first(); if (! $addon) { return false; } $lock = Cache::lock('addon:webhook:paypal:'.$orderId, 30); if (! $lock->get()) { Log::info('[PayPalAddonWebhook] lock busy', [ 'order_id' => $orderId, 'addon_id' => $addon->id, ]); return true; } try { if ($addon->status === 'completed' && $normalized === 'PAYMENT.CAPTURE.COMPLETED') { return true; } if (in_array($normalized, ['PAYMENT.CAPTURE.COMPLETED', 'CHECKOUT.ORDER.COMPLETED'], true)) { $captureId = $this->resolveCaptureId($resource, $normalized); $totals = $this->resolveTotals($resource); $this->addons->complete( $addon, $resource, $captureId, $orderId, $totals['total'] ?? null, $totals['currency'] ?? null, [ 'paypal_order_id' => $orderId, 'paypal_capture_id' => $captureId, 'paypal_status' => $resource['status'] ?? null, 'paypal_totals' => $totals ?: null, 'paypal_captured_at' => now()->toIso8601String(), ], ); return true; } if (in_array($normalized, ['PAYMENT.CAPTURE.DENIED', 'PAYMENT.CAPTURE.REFUNDED', 'CHECKOUT.ORDER.VOIDED'], true)) { $reason = match ($normalized) { 'PAYMENT.CAPTURE.DENIED' => 'paypal_capture_denied', 'PAYMENT.CAPTURE.REFUNDED' => 'paypal_refunded', 'CHECKOUT.ORDER.VOIDED' => 'paypal_voided', default => 'paypal_failed', }; $this->addons->fail($addon, $reason, $resource); return true; } return false; } finally { $lock->release(); } } /** * @param array $resource */ protected function resolveOrderId(string $eventType, array $resource): ?string { if (str_starts_with($eventType, 'PAYMENT.CAPTURE.')) { $relatedOrderId = Arr::get($resource, 'supplementary_data.related_ids.order_id'); return is_string($relatedOrderId) && $relatedOrderId !== '' ? $relatedOrderId : null; } $orderId = $resource['id'] ?? null; return is_string($orderId) && $orderId !== '' ? $orderId : null; } /** * @param array $resource */ protected function resolveCaptureId(array $resource, string $eventType): ?string { if (str_starts_with($eventType, 'PAYMENT.CAPTURE.')) { $captureId = $resource['id'] ?? null; return is_string($captureId) && $captureId !== '' ? $captureId : null; } $captureId = Arr::get($resource, 'purchase_units.0.payments.captures.0.id'); return is_string($captureId) && $captureId !== '' ? $captureId : null; } /** * @param array $resource * @return array{currency?: string, total?: float} */ protected function resolveTotals(array $resource): array { $amount = Arr::get($resource, 'amount') ?? Arr::get($resource, 'purchase_units.0.payments.captures.0.amount') ?? Arr::get($resource, 'purchase_units.0.amount'); if (! is_array($amount)) { return []; } $currency = Arr::get($amount, 'currency_code'); $total = Arr::get($amount, 'value'); return array_filter([ 'currency' => is_string($currency) ? strtoupper($currency) : null, 'total' => is_numeric($total) ? (float) $total : null, ], static fn ($value) => $value !== null); } }