Files
fotospiel-app/app/Services/Checkout/CheckoutWebhookService.php
Codex Agent 10c99de1e2
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Migrate billing from Paddle to Lemon Squeezy
2026-02-03 10:59:54 +01:00

500 lines
17 KiB
PHP

<?php
namespace App\Services\Checkout;
use App\Models\CheckoutSession;
use App\Models\Package;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Services\Coupons\CouponRedemptionService;
use App\Services\GiftVouchers\GiftVoucherService;
use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
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 LemonSqueezySubscriptionService $lemonsqueezySubscriptions,
private readonly CouponRedemptionService $couponRedemptions,
private readonly GiftVoucherService $giftVouchers,
) {}
public function handleLemonSqueezyEvent(array $event): bool
{
$eventType = $event['meta']['event_name'] ?? $event['event_name'] ?? null;
$data = $event['data'] ?? null;
if (! $eventType || ! is_array($data)) {
return false;
}
if (Str::startsWith($eventType, 'subscription_')) {
return $this->handleLemonSqueezySubscriptionEvent($eventType, $data, $event);
}
if ($this->isGiftVoucherEvent($event)) {
if ($eventType === 'order_created') {
$this->giftVouchers->issueFromLemonSqueezy($event);
return true;
}
return in_array($eventType, ['order_created', 'order_refunded', 'order_payment_failed', 'order_updated'], true);
}
$session = $this->locateLemonSqueezySession($event);
if (! $session) {
Log::info('[CheckoutWebhook] Lemon Squeezy session not resolved', [
'event_type' => $eventType,
'order_id' => $data['id'] ?? null,
]);
return false;
}
$orderId = $data['id'] ?? null;
$lockKey = 'checkout:webhook:lemonsqueezy:'.($orderId ?: $session->id);
$lock = Cache::lock($lockKey, 30);
if (! $lock->get()) {
Log::info('[CheckoutWebhook] Lemon Squeezy lock busy', [
'order_id' => $orderId,
'session_id' => $session->id,
]);
return true;
}
try {
if ($orderId) {
$session->forceFill([
'lemonsqueezy_order_id' => $orderId,
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
])->save();
} elseif ($session->provider !== CheckoutSession::PROVIDER_LEMONSQUEEZY) {
$session->forceFill(['provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY])->save();
}
$metadata = [
'lemonsqueezy_last_event' => $eventType,
'lemonsqueezy_order_id' => $orderId,
'lemonsqueezy_status' => data_get($data, 'attributes.status'),
'lemonsqueezy_last_update_at' => now()->toIso8601String(),
];
$checkoutId = data_get($data, 'attributes.checkout_id') ?? data_get($event, 'meta.custom_data.checkout_id');
if (! empty($checkoutId)) {
$metadata['lemonsqueezy_checkout_id'] = $checkoutId;
}
$this->mergeProviderMetadata($session, $metadata);
$customerId = data_get($data, 'attributes.customer_id')
?? data_get($data, 'relationships.customer.data.id');
if ($customerId && $session->tenant && ! $session->tenant->lemonsqueezy_customer_id) {
$session->tenant->forceFill([
'lemonsqueezy_customer_id' => (string) $customerId,
])->save();
}
return $this->applyLemonSqueezyEvent($session, $eventType, $data, $event);
} finally {
$lock->release();
}
}
protected function applyLemonSqueezyEvent(CheckoutSession $session, string $eventType, array $data, array $event): bool
{
$status = Str::lower((string) data_get($data, 'attributes.status', ''));
switch ($eventType) {
case 'order_created':
case 'order_updated':
$this->syncSessionTotals($session, $data);
if ($status === 'paid') {
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
$this->sessions->markProcessing($session, [
'lemonsqueezy_status' => $status ?: 'paid',
]);
$this->assignment->finalise($session, [
'source' => 'lemonsqueezy_webhook',
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
'provider_reference' => $data['id'] ?? null,
'payload' => $data,
]);
$this->sessions->markCompleted($session, now());
$this->couponRedemptions->recordSuccess($session, $data);
}
} else {
$this->sessions->markProcessing($session, [
'lemonsqueezy_status' => $status ?: null,
]);
}
return true;
case 'order_payment_failed':
$reason = $status ?: 'lemonsqueezy_failed';
$this->sessions->markFailed($session, $reason);
$this->couponRedemptions->recordFailure($session, $reason);
return true;
case 'order_refunded':
$this->sessions->markFailed($session, 'lemonsqueezy_refunded');
$this->couponRedemptions->recordFailure($session, 'lemonsqueezy_refunded');
return true;
default:
return false;
}
}
protected function syncSessionTotals(CheckoutSession $session, array $data): void
{
$totals = $this->normalizeLemonSqueezyTotals($data);
if ($totals === []) {
return;
}
$updates = [];
if (array_key_exists('subtotal', $totals)) {
$updates['amount_subtotal'] = $totals['subtotal'];
}
if (array_key_exists('discount', $totals)) {
$updates['amount_discount'] = $totals['discount'];
}
if (array_key_exists('total', $totals)) {
$updates['amount_total'] = $totals['total'];
}
if (! empty($totals['currency'])) {
$updates['currency'] = $totals['currency'];
}
if ($updates !== []) {
$session->forceFill($updates)->save();
}
$this->mergeProviderMetadata($session, [
'lemonsqueezy_totals' => $totals,
]);
}
/**
* @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float}
*/
protected function normalizeLemonSqueezyTotals(array $data): array
{
$attributes = Arr::get($data, 'attributes', []);
$currency = Arr::get($attributes, 'currency');
$subtotal = $this->convertMinorAmount(Arr::get($attributes, 'subtotal'));
$discount = $this->convertMinorAmount(Arr::get($attributes, 'discount_total'));
$tax = $this->convertMinorAmount(Arr::get($attributes, 'tax'));
$total = $this->convertMinorAmount(Arr::get($attributes, 'total'));
return array_filter([
'currency' => $currency ? strtoupper((string) $currency) : null,
'subtotal' => $subtotal,
'discount' => $discount,
'tax' => $tax,
'total' => $total,
], static fn ($value) => $value !== null);
}
protected function convertMinorAmount(mixed $value): ?float
{
if ($value === null || $value === '') {
return null;
}
if (is_array($value) && isset($value['amount'])) {
$value = $value['amount'];
}
if (! is_numeric($value)) {
return null;
}
return round(((float) $value) / 100, 2);
}
protected function handleLemonSqueezySubscriptionEvent(string $eventType, array $data, array $event): bool
{
$subscriptionId = $data['id'] ?? null;
if (! $subscriptionId) {
return false;
}
$customData = $this->extractCustomData($event);
$tenant = $this->resolveTenantFromSubscription($data, $customData, $subscriptionId);
if (! $tenant) {
Log::info('[CheckoutWebhook] Lemon Squeezy subscription tenant not resolved', [
'subscription_id' => $subscriptionId,
]);
return false;
}
$package = $this->resolvePackageFromSubscription($data, $customData, $subscriptionId);
if (! $package) {
Log::info('[CheckoutWebhook] Lemon Squeezy subscription package not resolved', [
'subscription_id' => $subscriptionId,
]);
return false;
}
$status = Str::lower((string) Arr::get($data, 'attributes.status', ''));
$expiresAt = $this->resolveSubscriptionExpiry($data);
$startedAt = $this->resolveSubscriptionStart($data);
$tenantPackage = TenantPackage::firstOrNew([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
]);
$tenantPackage->fill([
'lemonsqueezy_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 (in_array($eventType, ['subscription_cancelled', 'subscription_expired', 'subscription_paused'], true)) {
$tenantPackage->forceFill(['active' => false])->save();
}
$tenant->forceFill([
'subscription_status' => $this->mapSubscriptionStatus($status),
'subscription_expires_at' => $expiresAt,
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id ?: Arr::get($data, 'attributes.customer_id'),
])->save();
Log::info('[CheckoutWebhook] Lemon Squeezy 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 = Arr::get($data, 'attributes.customer_id') ?? Arr::get($data, 'relationships.customer.data.id');
if ($customerId) {
$tenant = Tenant::where('lemonsqueezy_customer_id', $customerId)->first();
if ($tenant) {
return $tenant;
}
}
$subscription = $this->lemonsqueezySubscriptions->retrieve($subscriptionId);
$customerId = Arr::get($subscription, 'attributes.customer_id') ?? Arr::get($subscription, 'relationships.customer.data.id');
if ($customerId) {
return Tenant::where('lemonsqueezy_customer_id', $customerId)->first();
}
return null;
}
protected function resolvePackageFromSubscription(array $data, array $metadata, string $subscriptionId): ?Package
{
if (isset($metadata['package_id'])) {
$package = Package::withTrashed()->find((int) $metadata['package_id']);
if ($package) {
return $package;
}
}
$variantId = Arr::get($data, 'attributes.variant_id') ?? Arr::get($data, 'relationships.variant.data.id');
if ($variantId) {
$package = Package::withTrashed()->where('lemonsqueezy_variant_id', $variantId)->first();
if ($package) {
return $package;
}
}
$subscription = $this->lemonsqueezySubscriptions->retrieve($subscriptionId);
$variantId = Arr::get($subscription, 'attributes.variant_id') ?? Arr::get($subscription, 'relationships.variant.data.id');
if ($variantId) {
return Package::withTrashed()->where('lemonsqueezy_variant_id', $variantId)->first();
}
return null;
}
protected function resolveSubscriptionExpiry(array $data): ?Carbon
{
$nextBilling = Arr::get($data, 'attributes.renews_at');
if ($nextBilling) {
return Carbon::parse($nextBilling);
}
$endsAt = Arr::get($data, 'attributes.ends_at');
return $endsAt ? Carbon::parse($endsAt) : null;
}
protected function resolveSubscriptionStart(array $data): Carbon
{
$created = Arr::get($data, 'attributes.created_at');
return $created ? Carbon::parse($created) : now();
}
protected function isSubscriptionActive(string $status): bool
{
return in_array($status, ['active', 'on_trial'], true);
}
protected function mapSubscriptionStatus(string $status): string
{
return match ($status) {
'active', 'on_trial' => 'active',
'past_due', 'unpaid', 'paused' => 'suspended',
'cancelled', 'expired' => 'expired',
default => 'free',
};
}
protected function mergeProviderMetadata(CheckoutSession $session, array $data): void
{
$session->provider_metadata = array_merge($session->provider_metadata ?? [], $data);
$session->save();
}
protected function isGiftVoucherEvent(array $event): bool
{
$metadata = $this->extractCustomData($event);
$type = is_array($metadata) ? ($metadata['type'] ?? $metadata['kind'] ?? $metadata['category'] ?? null) : null;
if ($type && in_array(strtolower($type), ['gift_card', 'gift_voucher'], true)) {
return true;
}
$variantId = data_get($event, 'data.attributes.variant_id')
?? Arr::get($metadata, 'lemonsqueezy_variant_id');
$tiers = collect(config('gift-vouchers.tiers', []))
->pluck('lemonsqueezy_variant_id')
->filter()
->all();
return $variantId && in_array($variantId, $tiers, true);
}
protected function locateLemonSqueezySession(array $event): ?CheckoutSession
{
$metadata = $this->extractCustomData($event);
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;
}
}
}
$checkoutId = data_get($event, 'data.attributes.checkout_id')
?? Arr::get($metadata, 'lemonsqueezy_checkout_id')
?? Arr::get($metadata, 'checkout_id');
if ($checkoutId) {
return CheckoutSession::query()
->where('provider_metadata->lemonsqueezy_checkout_id', $checkoutId)
->first();
}
return null;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function extractCustomData(array $data): array
{
$customData = [];
if (isset($data['meta']['custom_data']) && is_array($data['meta']['custom_data'])) {
$customData = $data['meta']['custom_data'];
}
if (isset($data['attributes']['custom_data']) && is_array($data['attributes']['custom_data'])) {
$customData = array_merge($customData, $data['attributes']['custom_data']);
}
if (isset($data['custom_data']) && is_array($data['custom_data'])) {
$customData = array_merge($customData, $data['custom_data']);
}
if (isset($data['customData']) && is_array($data['customData'])) {
$customData = array_merge($customData, $data['customData']);
}
if (isset($data['metadata']) && is_array($data['metadata'])) {
$customData = array_merge($customData, $data['metadata']);
}
return $customData;
}
}