switched to paddle inline checkout, removed paypal and most of stripe. added product sync between app and paddle.
This commit is contained in:
@@ -3,17 +3,23 @@
|
||||
namespace App\Services\Checkout;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Paddle\PaddleSubscriptionService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CheckoutWebhookService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
private readonly CheckoutAssignmentService $assignment,
|
||||
) {
|
||||
}
|
||||
private readonly PaddleSubscriptionService $paddleSubscriptions,
|
||||
) {}
|
||||
|
||||
public function handleStripeEvent(array $event): bool
|
||||
{
|
||||
@@ -72,29 +78,37 @@ class CheckoutWebhookService
|
||||
}
|
||||
}
|
||||
|
||||
public function handlePayPalEvent(array $event): bool
|
||||
public function handlePaddleEvent(array $event): bool
|
||||
{
|
||||
$eventType = $event['event_type'] ?? null;
|
||||
$resource = $event['resource'] ?? [];
|
||||
$data = $event['data'] ?? [];
|
||||
|
||||
if (! $eventType || ! is_array($resource)) {
|
||||
if (! $eventType || ! is_array($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$orderId = $resource['order_id'] ?? $resource['id'] ?? null;
|
||||
if (Str::startsWith($eventType, 'subscription.')) {
|
||||
return $this->handlePaddleSubscriptionEvent($eventType, $data);
|
||||
}
|
||||
|
||||
$session = $this->locatePayPalSession($resource, $orderId);
|
||||
$session = $this->locatePaddleSession($data);
|
||||
|
||||
if (! $session) {
|
||||
Log::info('[CheckoutWebhook] Paddle session not resolved', [
|
||||
'event_type' => $eventType,
|
||||
'transaction_id' => $data['id'] ?? null,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$lockKey = "checkout:webhook:paypal:".($orderId ?: $session->id);
|
||||
$transactionId = $data['id'] ?? $data['transaction_id'] ?? null;
|
||||
$lockKey = 'checkout:webhook:paddle:'.($transactionId ?: $session->id);
|
||||
$lock = Cache::lock($lockKey, 30);
|
||||
|
||||
if (! $lock->get()) {
|
||||
Log::info('[CheckoutWebhook] PayPal lock busy', [
|
||||
'order_id' => $orderId,
|
||||
Log::info('[CheckoutWebhook] Paddle lock busy', [
|
||||
'transaction_id' => $transactionId,
|
||||
'session_id' => $session->id,
|
||||
]);
|
||||
|
||||
@@ -102,22 +116,29 @@ class CheckoutWebhookService
|
||||
}
|
||||
|
||||
try {
|
||||
$session->forceFill([
|
||||
'paypal_order_id' => $orderId ?: $session->paypal_order_id,
|
||||
'provider' => CheckoutSession::PROVIDER_PAYPAL,
|
||||
])->save();
|
||||
if ($transactionId) {
|
||||
$session->forceFill([
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
])->save();
|
||||
} elseif ($session->provider !== CheckoutSession::PROVIDER_PADDLE) {
|
||||
$session->forceFill(['provider' => CheckoutSession::PROVIDER_PADDLE])->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,
|
||||
'paddle_last_event' => $eventType,
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'paddle_status' => $data['status'] ?? null,
|
||||
'paddle_last_update_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
if (! empty($data['checkout_id'])) {
|
||||
$metadata['paddle_checkout_id'] = $data['checkout_id'];
|
||||
}
|
||||
|
||||
$this->mergeProviderMetadata($session, $metadata);
|
||||
|
||||
return $this->applyPayPalEvent($session, $eventType, $resource);
|
||||
return $this->applyPaddleEvent($session, $eventType, $data);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
@@ -131,16 +152,19 @@ class CheckoutWebhookService
|
||||
$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':
|
||||
@@ -165,25 +189,30 @@ class CheckoutWebhookService
|
||||
}
|
||||
}
|
||||
|
||||
protected function applyPayPalEvent(CheckoutSession $session, string $eventType, array $resource): bool
|
||||
protected function applyPaddleEvent(CheckoutSession $session, string $eventType, array $data): bool
|
||||
{
|
||||
$status = strtolower((string) ($data['status'] ?? ''));
|
||||
|
||||
switch ($eventType) {
|
||||
case 'CHECKOUT.ORDER.APPROVED':
|
||||
case 'transaction.created':
|
||||
case 'transaction.processing':
|
||||
$this->sessions->markProcessing($session, [
|
||||
'paypal_order_status' => $resource['status'] ?? null,
|
||||
'paddle_status' => $status ?: null,
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
case 'PAYMENT.CAPTURE.COMPLETED':
|
||||
case 'transaction.completed':
|
||||
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
|
||||
$this->sessions->markProcessing($session, [
|
||||
'paypal_order_status' => $resource['status'] ?? null,
|
||||
'paddle_status' => $status ?: 'completed',
|
||||
]);
|
||||
|
||||
$this->assignment->finalise($session, [
|
||||
'source' => 'paypal_webhook',
|
||||
'paypal_order_id' => $resource['order_id'] ?? null,
|
||||
'paypal_capture_id' => $resource['id'] ?? null,
|
||||
'source' => 'paddle_webhook',
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
'provider_reference' => $data['id'] ?? null,
|
||||
'payload' => $data,
|
||||
]);
|
||||
|
||||
$this->sessions->markCompleted($session, now());
|
||||
@@ -191,8 +220,11 @@ class CheckoutWebhookService
|
||||
|
||||
return true;
|
||||
|
||||
case 'PAYMENT.CAPTURE.DENIED':
|
||||
$this->sessions->markFailed($session, 'paypal_capture_denied');
|
||||
case 'transaction.failed':
|
||||
case 'transaction.cancelled':
|
||||
$reason = $status ?: ($eventType === 'transaction.failed' ? 'paddle_failed' : 'paddle_cancelled');
|
||||
$this->sessions->markFailed($session, $reason);
|
||||
|
||||
return true;
|
||||
|
||||
default:
|
||||
@@ -200,6 +232,169 @@ class CheckoutWebhookService
|
||||
}
|
||||
}
|
||||
|
||||
protected function handlePaddleSubscriptionEvent(string $eventType, array $data): bool
|
||||
{
|
||||
$subscriptionId = $data['id'] ?? null;
|
||||
|
||||
if (! $subscriptionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$metadata = $data['metadata'] ?? [];
|
||||
$tenant = $this->resolveTenantFromSubscription($data, $metadata, $subscriptionId);
|
||||
|
||||
if (! $tenant) {
|
||||
Log::info('[CheckoutWebhook] Paddle subscription tenant not resolved', [
|
||||
'subscription_id' => $subscriptionId,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$package = $this->resolvePackageFromSubscription($data, $metadata, $subscriptionId);
|
||||
|
||||
if (! $package) {
|
||||
Log::info('[CheckoutWebhook] Paddle subscription package not resolved', [
|
||||
'subscription_id' => $subscriptionId,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = strtolower((string) ($data['status'] ?? ''));
|
||||
$expiresAt = $this->resolveSubscriptionExpiry($data);
|
||||
$startedAt = $this->resolveSubscriptionStart($data);
|
||||
|
||||
$tenantPackage = TenantPackage::firstOrNew([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
$tenantPackage->fill([
|
||||
'paddle_subscription_id' => $subscriptionId,
|
||||
'price' => $package->price,
|
||||
]);
|
||||
|
||||
$tenantPackage->expires_at = $expiresAt ?? $tenantPackage->expires_at ?? $startedAt?->copy()->addYear();
|
||||
$tenantPackage->purchased_at = $tenantPackage->purchased_at
|
||||
?? $tenant->purchases()->where('package_id', $package->id)->latest('purchased_at')->value('purchased_at')
|
||||
?? $startedAt;
|
||||
|
||||
$tenantPackage->active = $this->isSubscriptionActive($status);
|
||||
$tenantPackage->save();
|
||||
|
||||
if ($eventType === 'subscription.cancelled' || $eventType === 'subscription.paused') {
|
||||
$tenantPackage->forceFill(['active' => false])->save();
|
||||
}
|
||||
|
||||
$tenant->forceFill([
|
||||
'subscription_status' => $this->mapSubscriptionStatus($status),
|
||||
'subscription_expires_at' => $expiresAt,
|
||||
'paddle_customer_id' => $tenant->paddle_customer_id ?: ($data['customer_id'] ?? null),
|
||||
])->save();
|
||||
|
||||
Log::info('[CheckoutWebhook] Paddle subscription event processed', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'subscription_id' => $subscriptionId,
|
||||
'event_type' => $eventType,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function resolveTenantFromSubscription(array $data, array $metadata, string $subscriptionId): ?Tenant
|
||||
{
|
||||
if (isset($metadata['tenant_id'])) {
|
||||
$tenant = Tenant::find((int) $metadata['tenant_id']);
|
||||
if ($tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
$customerId = $data['customer_id'] ?? null;
|
||||
|
||||
if ($customerId) {
|
||||
$tenant = Tenant::where('paddle_customer_id', $customerId)->first();
|
||||
if ($tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
$subscription = $this->paddleSubscriptions->retrieve($subscriptionId);
|
||||
$customerId = Arr::get($subscription, 'data.customer_id');
|
||||
|
||||
if ($customerId) {
|
||||
return Tenant::where('paddle_customer_id', $customerId)->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolvePackageFromSubscription(array $data, array $metadata, string $subscriptionId): ?Package
|
||||
{
|
||||
if (isset($metadata['package_id'])) {
|
||||
$package = Package::find((int) $metadata['package_id']);
|
||||
if ($package) {
|
||||
return $package;
|
||||
}
|
||||
}
|
||||
|
||||
$priceId = Arr::get($data, 'items.0.price_id') ?? Arr::get($data, 'items.0.price.id');
|
||||
|
||||
if ($priceId) {
|
||||
$package = Package::where('paddle_price_id', $priceId)->first();
|
||||
if ($package) {
|
||||
return $package;
|
||||
}
|
||||
}
|
||||
|
||||
$subscription = $this->paddleSubscriptions->retrieve($subscriptionId);
|
||||
$priceId = Arr::get($subscription, 'data.items.0.price_id') ?? Arr::get($subscription, 'data.items.0.price.id');
|
||||
|
||||
if ($priceId) {
|
||||
return Package::where('paddle_price_id', $priceId)->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function resolveSubscriptionExpiry(array $data): ?Carbon
|
||||
{
|
||||
$nextBilling = Arr::get($data, 'next_billing_date') ?? Arr::get($data, 'next_payment_date');
|
||||
|
||||
if ($nextBilling) {
|
||||
return Carbon::parse($nextBilling);
|
||||
}
|
||||
|
||||
$endsAt = Arr::get($data, 'billing_period_ends_at') ?? Arr::get($data, 'pays_out_at');
|
||||
|
||||
return $endsAt ? Carbon::parse($endsAt) : null;
|
||||
}
|
||||
|
||||
protected function resolveSubscriptionStart(array $data): Carbon
|
||||
{
|
||||
$created = Arr::get($data, 'created_at') ?? Arr::get($data, 'activated_at');
|
||||
|
||||
return $created ? Carbon::parse($created) : now();
|
||||
}
|
||||
|
||||
protected function isSubscriptionActive(string $status): bool
|
||||
{
|
||||
return in_array($status, ['active', 'trialing'], true);
|
||||
}
|
||||
|
||||
protected function mapSubscriptionStatus(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'active', 'trialing' => 'active',
|
||||
'paused' => 'suspended',
|
||||
'cancelled', 'past_due', 'halted' => 'expired',
|
||||
default => 'free',
|
||||
};
|
||||
}
|
||||
|
||||
protected function mergeProviderMetadata(CheckoutSession $session, array $data): void
|
||||
{
|
||||
$session->provider_metadata = array_merge($session->provider_metadata ?? [], $data);
|
||||
@@ -230,42 +425,45 @@ class CheckoutWebhookService
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function locatePayPalSession(array $resource, ?string $orderId): ?CheckoutSession
|
||||
protected function locatePaddleSession(array $data): ?CheckoutSession
|
||||
{
|
||||
if ($orderId) {
|
||||
$session = CheckoutSession::query()
|
||||
->where('paypal_order_id', $orderId)
|
||||
->first();
|
||||
$metadata = $data['metadata'] ?? [];
|
||||
|
||||
if ($session) {
|
||||
if (is_array($metadata)) {
|
||||
$sessionId = $metadata['checkout_session_id'] ?? null;
|
||||
|
||||
if ($sessionId && $session = CheckoutSession::find($sessionId)) {
|
||||
return $session;
|
||||
}
|
||||
|
||||
$tenantId = $metadata['tenant_id'] ?? null;
|
||||
$packageId = $metadata['package_id'] ?? null;
|
||||
|
||||
if ($tenantId && $packageId) {
|
||||
$session = CheckoutSession::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('package_id', $packageId)
|
||||
->whereNotIn('status', [CheckoutSession::STATUS_COMPLETED, CheckoutSession::STATUS_CANCELLED])
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($session) {
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$metadata = $this->extractPayPalMetadata($resource);
|
||||
$sessionId = $metadata['checkout_session_id'] ?? null;
|
||||
$checkoutId = $data['checkout_id'] ?? Arr::get($data, 'details.checkout_id');
|
||||
|
||||
if ($sessionId) {
|
||||
return CheckoutSession::find($sessionId);
|
||||
if ($checkoutId) {
|
||||
return CheckoutSession::query()
|
||||
->where('provider_metadata->paddle_checkout_id', $checkoutId)
|
||||
->first();
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -276,4 +474,3 @@ class CheckoutWebhookService
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user