Migrate billing from Paddle to Lemon Squeezy
This commit is contained in:
@@ -8,7 +8,7 @@ use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Coupons\CouponRedemptionService;
|
||||
use App\Services\GiftVouchers\GiftVoucherService;
|
||||
use App\Services\Paddle\PaddleSubscriptionService;
|
||||
use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -20,52 +20,52 @@ class CheckoutWebhookService
|
||||
public function __construct(
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
private readonly CheckoutAssignmentService $assignment,
|
||||
private readonly PaddleSubscriptionService $paddleSubscriptions,
|
||||
private readonly LemonSqueezySubscriptionService $lemonsqueezySubscriptions,
|
||||
private readonly CouponRedemptionService $couponRedemptions,
|
||||
private readonly GiftVoucherService $giftVouchers,
|
||||
) {}
|
||||
|
||||
public function handlePaddleEvent(array $event): bool
|
||||
public function handleLemonSqueezyEvent(array $event): bool
|
||||
{
|
||||
$eventType = $event['event_type'] ?? null;
|
||||
$data = $event['data'] ?? [];
|
||||
$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->handlePaddleSubscriptionEvent($eventType, $data);
|
||||
if (Str::startsWith($eventType, 'subscription_')) {
|
||||
return $this->handleLemonSqueezySubscriptionEvent($eventType, $data, $event);
|
||||
}
|
||||
|
||||
if ($this->isGiftVoucherEvent($data)) {
|
||||
if ($eventType === 'transaction.completed') {
|
||||
$this->giftVouchers->issueFromPaddle($data);
|
||||
if ($this->isGiftVoucherEvent($event)) {
|
||||
if ($eventType === 'order_created') {
|
||||
$this->giftVouchers->issueFromLemonSqueezy($event);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($eventType, ['transaction.processing', 'transaction.created', 'transaction.failed', 'transaction.cancelled'], true);
|
||||
return in_array($eventType, ['order_created', 'order_refunded', 'order_payment_failed', 'order_updated'], true);
|
||||
}
|
||||
|
||||
$session = $this->locatePaddleSession($data);
|
||||
$session = $this->locateLemonSqueezySession($event);
|
||||
|
||||
if (! $session) {
|
||||
Log::info('[CheckoutWebhook] Paddle session not resolved', [
|
||||
Log::info('[CheckoutWebhook] Lemon Squeezy session not resolved', [
|
||||
'event_type' => $eventType,
|
||||
'transaction_id' => $data['id'] ?? null,
|
||||
'order_id' => $data['id'] ?? null,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$transactionId = $data['id'] ?? $data['transaction_id'] ?? null;
|
||||
$lockKey = 'checkout:webhook:paddle:'.($transactionId ?: $session->id);
|
||||
$orderId = $data['id'] ?? null;
|
||||
$lockKey = 'checkout:webhook:lemonsqueezy:'.($orderId ?: $session->id);
|
||||
$lock = Cache::lock($lockKey, 30);
|
||||
|
||||
if (! $lock->get()) {
|
||||
Log::info('[CheckoutWebhook] Paddle lock busy', [
|
||||
'transaction_id' => $transactionId,
|
||||
Log::info('[CheckoutWebhook] Lemon Squeezy lock busy', [
|
||||
'order_id' => $orderId,
|
||||
'session_id' => $session->id,
|
||||
]);
|
||||
|
||||
@@ -73,75 +73,90 @@ class CheckoutWebhookService
|
||||
}
|
||||
|
||||
try {
|
||||
if ($transactionId) {
|
||||
if ($orderId) {
|
||||
$session->forceFill([
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
'lemonsqueezy_order_id' => $orderId,
|
||||
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
|
||||
])->save();
|
||||
} elseif ($session->provider !== CheckoutSession::PROVIDER_PADDLE) {
|
||||
$session->forceFill(['provider' => CheckoutSession::PROVIDER_PADDLE])->save();
|
||||
} elseif ($session->provider !== CheckoutSession::PROVIDER_LEMONSQUEEZY) {
|
||||
$session->forceFill(['provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY])->save();
|
||||
}
|
||||
|
||||
$metadata = [
|
||||
'paddle_last_event' => $eventType,
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'paddle_status' => $data['status'] ?? null,
|
||||
'paddle_last_update_at' => now()->toIso8601String(),
|
||||
'lemonsqueezy_last_event' => $eventType,
|
||||
'lemonsqueezy_order_id' => $orderId,
|
||||
'lemonsqueezy_status' => data_get($data, 'attributes.status'),
|
||||
'lemonsqueezy_last_update_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
if (! empty($data['checkout_id'])) {
|
||||
$metadata['paddle_checkout_id'] = $data['checkout_id'];
|
||||
$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);
|
||||
|
||||
return $this->applyPaddleEvent($session, $eventType, $data);
|
||||
$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 applyPaddleEvent(CheckoutSession $session, string $eventType, array $data): bool
|
||||
protected function applyLemonSqueezyEvent(CheckoutSession $session, string $eventType, array $data, array $event): bool
|
||||
{
|
||||
$status = strtolower((string) ($data['status'] ?? ''));
|
||||
$status = Str::lower((string) data_get($data, 'attributes.status', ''));
|
||||
|
||||
switch ($eventType) {
|
||||
case 'transaction.created':
|
||||
case 'transaction.processing':
|
||||
$this->sessions->markProcessing($session, [
|
||||
'paddle_status' => $status ?: null,
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
case 'transaction.completed':
|
||||
case 'order_created':
|
||||
case 'order_updated':
|
||||
$this->syncSessionTotals($session, $data);
|
||||
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
|
||||
|
||||
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, [
|
||||
'paddle_status' => $status ?: 'completed',
|
||||
'lemonsqueezy_status' => $status ?: null,
|
||||
]);
|
||||
|
||||
$this->assignment->finalise($session, [
|
||||
'source' => 'paddle_webhook',
|
||||
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||
'provider_reference' => $data['id'] ?? null,
|
||||
'payload' => $data,
|
||||
]);
|
||||
|
||||
$this->sessions->markCompleted($session, now());
|
||||
$this->couponRedemptions->recordSuccess($session, $data);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
case 'transaction.failed':
|
||||
case 'transaction.cancelled':
|
||||
$reason = $status ?: ($eventType === 'transaction.failed' ? 'paddle_failed' : 'paddle_cancelled');
|
||||
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;
|
||||
}
|
||||
@@ -149,7 +164,7 @@ class CheckoutWebhookService
|
||||
|
||||
protected function syncSessionTotals(CheckoutSession $session, array $data): void
|
||||
{
|
||||
$totals = $this->normalizePaddleTotals($data);
|
||||
$totals = $this->normalizeLemonSqueezyTotals($data);
|
||||
|
||||
if ($totals === []) {
|
||||
return;
|
||||
@@ -178,29 +193,22 @@ class CheckoutWebhookService
|
||||
}
|
||||
|
||||
$this->mergeProviderMetadata($session, [
|
||||
'paddle_totals' => $totals,
|
||||
'lemonsqueezy_totals' => $totals,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float}
|
||||
*/
|
||||
protected function normalizePaddleTotals(array $data): array
|
||||
protected function normalizeLemonSqueezyTotals(array $data): array
|
||||
{
|
||||
$totals = Arr::get($data, 'details.totals', Arr::get($data, 'totals', []));
|
||||
$currency = Arr::get($totals, 'currency_code')
|
||||
?? $data['currency_code'] ?? Arr::get($totals, 'currency') ?? Arr::get($data, 'currency');
|
||||
$attributes = Arr::get($data, 'attributes', []);
|
||||
$currency = Arr::get($attributes, 'currency');
|
||||
|
||||
$subtotal = $this->convertMinorAmount(Arr::get($totals, 'subtotal.amount', $totals['subtotal'] ?? null));
|
||||
$discount = $this->convertMinorAmount(Arr::get($totals, 'discount.amount', $totals['discount'] ?? null));
|
||||
$tax = $this->convertMinorAmount(Arr::get($totals, 'tax.amount', $totals['tax'] ?? null));
|
||||
$total = $this->convertMinorAmount(
|
||||
Arr::get(
|
||||
$totals,
|
||||
'total.amount',
|
||||
$totals['total'] ?? Arr::get($totals, 'grand_total.amount', $totals['grand_total'] ?? null)
|
||||
)
|
||||
);
|
||||
$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,
|
||||
@@ -228,7 +236,7 @@ class CheckoutWebhookService
|
||||
return round(((float) $value) / 100, 2);
|
||||
}
|
||||
|
||||
protected function handlePaddleSubscriptionEvent(string $eventType, array $data): bool
|
||||
protected function handleLemonSqueezySubscriptionEvent(string $eventType, array $data, array $event): bool
|
||||
{
|
||||
$subscriptionId = $data['id'] ?? null;
|
||||
|
||||
@@ -236,11 +244,11 @@ class CheckoutWebhookService
|
||||
return false;
|
||||
}
|
||||
|
||||
$customData = $this->extractCustomData($data);
|
||||
$customData = $this->extractCustomData($event);
|
||||
$tenant = $this->resolveTenantFromSubscription($data, $customData, $subscriptionId);
|
||||
|
||||
if (! $tenant) {
|
||||
Log::info('[CheckoutWebhook] Paddle subscription tenant not resolved', [
|
||||
Log::info('[CheckoutWebhook] Lemon Squeezy subscription tenant not resolved', [
|
||||
'subscription_id' => $subscriptionId,
|
||||
]);
|
||||
|
||||
@@ -250,14 +258,14 @@ class CheckoutWebhookService
|
||||
$package = $this->resolvePackageFromSubscription($data, $customData, $subscriptionId);
|
||||
|
||||
if (! $package) {
|
||||
Log::info('[CheckoutWebhook] Paddle subscription package not resolved', [
|
||||
Log::info('[CheckoutWebhook] Lemon Squeezy subscription package not resolved', [
|
||||
'subscription_id' => $subscriptionId,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = strtolower((string) ($data['status'] ?? ''));
|
||||
$status = Str::lower((string) Arr::get($data, 'attributes.status', ''));
|
||||
$expiresAt = $this->resolveSubscriptionExpiry($data);
|
||||
$startedAt = $this->resolveSubscriptionStart($data);
|
||||
|
||||
@@ -267,7 +275,7 @@ class CheckoutWebhookService
|
||||
]);
|
||||
|
||||
$tenantPackage->fill([
|
||||
'paddle_subscription_id' => $subscriptionId,
|
||||
'lemonsqueezy_subscription_id' => $subscriptionId,
|
||||
'price' => $package->price,
|
||||
]);
|
||||
|
||||
@@ -279,17 +287,17 @@ class CheckoutWebhookService
|
||||
$tenantPackage->active = $this->isSubscriptionActive($status);
|
||||
$tenantPackage->save();
|
||||
|
||||
if ($eventType === 'subscription.cancelled' || $eventType === 'subscription.paused') {
|
||||
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,
|
||||
'paddle_customer_id' => $tenant->paddle_customer_id ?: ($data['customer_id'] ?? null),
|
||||
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id ?: Arr::get($data, 'attributes.customer_id'),
|
||||
])->save();
|
||||
|
||||
Log::info('[CheckoutWebhook] Paddle subscription event processed', [
|
||||
Log::info('[CheckoutWebhook] Lemon Squeezy subscription event processed', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'subscription_id' => $subscriptionId,
|
||||
@@ -309,20 +317,20 @@ class CheckoutWebhookService
|
||||
}
|
||||
}
|
||||
|
||||
$customerId = $data['customer_id'] ?? null;
|
||||
$customerId = Arr::get($data, 'attributes.customer_id') ?? Arr::get($data, 'relationships.customer.data.id');
|
||||
|
||||
if ($customerId) {
|
||||
$tenant = Tenant::where('paddle_customer_id', $customerId)->first();
|
||||
$tenant = Tenant::where('lemonsqueezy_customer_id', $customerId)->first();
|
||||
if ($tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
$subscription = $this->paddleSubscriptions->retrieve($subscriptionId);
|
||||
$customerId = Arr::get($subscription, 'data.customer_id');
|
||||
$subscription = $this->lemonsqueezySubscriptions->retrieve($subscriptionId);
|
||||
$customerId = Arr::get($subscription, 'attributes.customer_id') ?? Arr::get($subscription, 'relationships.customer.data.id');
|
||||
|
||||
if ($customerId) {
|
||||
return Tenant::where('paddle_customer_id', $customerId)->first();
|
||||
return Tenant::where('lemonsqueezy_customer_id', $customerId)->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -337,20 +345,20 @@ class CheckoutWebhookService
|
||||
}
|
||||
}
|
||||
|
||||
$priceId = Arr::get($data, 'items.0.price_id') ?? Arr::get($data, 'items.0.price.id');
|
||||
$variantId = Arr::get($data, 'attributes.variant_id') ?? Arr::get($data, 'relationships.variant.data.id');
|
||||
|
||||
if ($priceId) {
|
||||
$package = Package::withTrashed()->where('paddle_price_id', $priceId)->first();
|
||||
if ($variantId) {
|
||||
$package = Package::withTrashed()->where('lemonsqueezy_variant_id', $variantId)->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');
|
||||
$subscription = $this->lemonsqueezySubscriptions->retrieve($subscriptionId);
|
||||
$variantId = Arr::get($subscription, 'attributes.variant_id') ?? Arr::get($subscription, 'relationships.variant.data.id');
|
||||
|
||||
if ($priceId) {
|
||||
return Package::withTrashed()->where('paddle_price_id', $priceId)->first();
|
||||
if ($variantId) {
|
||||
return Package::withTrashed()->where('lemonsqueezy_variant_id', $variantId)->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -358,35 +366,35 @@ class CheckoutWebhookService
|
||||
|
||||
protected function resolveSubscriptionExpiry(array $data): ?Carbon
|
||||
{
|
||||
$nextBilling = Arr::get($data, 'next_billing_date') ?? Arr::get($data, 'next_payment_date');
|
||||
$nextBilling = Arr::get($data, 'attributes.renews_at');
|
||||
|
||||
if ($nextBilling) {
|
||||
return Carbon::parse($nextBilling);
|
||||
}
|
||||
|
||||
$endsAt = Arr::get($data, 'billing_period_ends_at') ?? Arr::get($data, 'pays_out_at');
|
||||
$endsAt = Arr::get($data, 'attributes.ends_at');
|
||||
|
||||
return $endsAt ? Carbon::parse($endsAt) : null;
|
||||
}
|
||||
|
||||
protected function resolveSubscriptionStart(array $data): Carbon
|
||||
{
|
||||
$created = Arr::get($data, 'created_at') ?? Arr::get($data, 'activated_at');
|
||||
$created = Arr::get($data, 'attributes.created_at');
|
||||
|
||||
return $created ? Carbon::parse($created) : now();
|
||||
}
|
||||
|
||||
protected function isSubscriptionActive(string $status): bool
|
||||
{
|
||||
return in_array($status, ['active', 'trialing'], true);
|
||||
return in_array($status, ['active', 'on_trial'], true);
|
||||
}
|
||||
|
||||
protected function mapSubscriptionStatus(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'active', 'trialing' => 'active',
|
||||
'paused' => 'suspended',
|
||||
'cancelled', 'past_due', 'halted' => 'expired',
|
||||
'active', 'on_trial' => 'active',
|
||||
'past_due', 'unpaid', 'paused' => 'suspended',
|
||||
'cancelled', 'expired' => 'expired',
|
||||
default => 'free',
|
||||
};
|
||||
}
|
||||
@@ -397,9 +405,9 @@ class CheckoutWebhookService
|
||||
$session->save();
|
||||
}
|
||||
|
||||
protected function isGiftVoucherEvent(array $data): bool
|
||||
protected function isGiftVoucherEvent(array $event): bool
|
||||
{
|
||||
$metadata = $this->extractCustomData($data);
|
||||
$metadata = $this->extractCustomData($event);
|
||||
|
||||
$type = is_array($metadata) ? ($metadata['type'] ?? $metadata['kind'] ?? $metadata['category'] ?? null) : null;
|
||||
|
||||
@@ -407,18 +415,19 @@ class CheckoutWebhookService
|
||||
return true;
|
||||
}
|
||||
|
||||
$priceId = $data['price_id'] ?? Arr::get($metadata, 'paddle_price_id');
|
||||
$variantId = data_get($event, 'data.attributes.variant_id')
|
||||
?? Arr::get($metadata, 'lemonsqueezy_variant_id');
|
||||
$tiers = collect(config('gift-vouchers.tiers', []))
|
||||
->pluck('paddle_price_id')
|
||||
->pluck('lemonsqueezy_variant_id')
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
return $priceId && in_array($priceId, $tiers, true);
|
||||
return $variantId && in_array($variantId, $tiers, true);
|
||||
}
|
||||
|
||||
protected function locatePaddleSession(array $data): ?CheckoutSession
|
||||
protected function locateLemonSqueezySession(array $event): ?CheckoutSession
|
||||
{
|
||||
$metadata = $this->extractCustomData($data);
|
||||
$metadata = $this->extractCustomData($event);
|
||||
|
||||
if (is_array($metadata)) {
|
||||
$sessionId = $metadata['checkout_session_id'] ?? null;
|
||||
@@ -444,11 +453,13 @@ class CheckoutWebhookService
|
||||
}
|
||||
}
|
||||
|
||||
$checkoutId = $data['checkout_id'] ?? Arr::get($data, 'details.checkout_id');
|
||||
$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->paddle_checkout_id', $checkoutId)
|
||||
->where('provider_metadata->lemonsqueezy_checkout_id', $checkoutId)
|
||||
->first();
|
||||
}
|
||||
|
||||
@@ -463,8 +474,16 @@ class CheckoutWebhookService
|
||||
{
|
||||
$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 = $data['custom_data'];
|
||||
$customData = array_merge($customData, $data['custom_data']);
|
||||
}
|
||||
|
||||
if (isset($data['customData']) && is_array($data['customData'])) {
|
||||
|
||||
Reference in New Issue
Block a user