Migrate billing from Paddle to Lemon Squeezy

This commit is contained in:
Codex Agent
2026-02-03 10:59:54 +01:00
parent 2f4ebfefd4
commit a0ef90e13a
228 changed files with 4369 additions and 4067 deletions

View File

@@ -2,34 +2,32 @@
namespace App\Services\GiftVouchers;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleClient;
use Illuminate\Support\Arr;
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class GiftVoucherCheckoutService
{
public function __construct(private readonly PaddleClient $client) {}
public function __construct(private readonly LemonSqueezyCheckoutService $checkout) {}
/**
* @return array<int, array{key:string,label:string,amount:float,currency:string,paddle_price_id?:string|null,can_checkout:bool}>
* @return array<int, array{key:string,label:string,amount:float,currency:string,lemonsqueezy_variant_id?:string|null,can_checkout:bool}>
*/
public function tiers(): array
{
return collect(config('gift-vouchers.tiers', []))
->map(function (array $tier): array {
$currency = Str::upper($tier['currency'] ?? 'EUR');
$priceId = $tier['paddle_price_id'] ?? null;
$variantId = $tier['lemonsqueezy_variant_id'] ?? null;
return [
'key' => $tier['key'],
'label' => $tier['label'],
'amount' => (float) $tier['amount'],
'currency' => $currency,
'paddle_price_id' => $priceId,
'can_checkout' => ! empty($priceId),
'lemonsqueezy_variant_id' => $variantId,
'can_checkout' => ! empty($variantId),
];
})
->values()
@@ -44,47 +42,34 @@ class GiftVoucherCheckoutService
{
$tier = $this->findTier($data['tier_key']);
if (! $tier || empty($tier['paddle_price_id'])) {
if (! $tier || empty($tier['lemonsqueezy_variant_id'])) {
throw ValidationException::withMessages([
'tier_key' => __('Gift voucher is not available right now.'),
]);
}
$customerId = $this->ensureCustomerId($data['purchaser_email']);
$customData = array_filter([
'type' => 'gift_voucher',
'tier_key' => $tier['key'],
'purchaser_email' => $data['purchaser_email'],
'recipient_email' => $data['recipient_email'] ?? null,
'recipient_name' => $data['recipient_name'] ?? null,
'message' => $data['message'] ?? null,
'app_locale' => App::getLocale(),
'success_url' => $data['success_url'] ?? null,
'return_url' => $data['return_url'] ?? null,
'lemonsqueezy_variant_id' => $tier['lemonsqueezy_variant_id'],
], static fn ($value) => $value !== null && $value !== '');
$payload = [
'items' => [
[
'price_id' => $tier['paddle_price_id'],
'quantity' => 1,
],
],
'customer_id' => $customerId,
'custom_data' => array_filter([
'type' => 'gift_voucher',
'tier_key' => $tier['key'],
'purchaser_email' => $data['purchaser_email'],
'recipient_email' => $data['recipient_email'] ?? null,
'recipient_name' => $data['recipient_name'] ?? null,
'message' => $data['message'] ?? null,
'app_locale' => App::getLocale(),
return $this->checkout->createVariantCheckout(
(string) $tier['lemonsqueezy_variant_id'],
$customData,
[
'success_url' => $data['success_url'] ?? null,
'cancel_url' => $data['return_url'] ?? null,
]),
];
$response = $this->client->post('/transactions', $payload);
return [
'checkout_url' => Arr::get($response, 'data.checkout.url')
?? Arr::get($response, 'checkout.url')
?? Arr::get($response, 'data.url')
?? Arr::get($response, 'url'),
'expires_at' => Arr::get($response, 'data.checkout.expires_at')
?? Arr::get($response, 'data.expires_at')
?? Arr::get($response, 'expires_at'),
'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'),
];
'return_url' => $data['return_url'] ?? null,
'customer_email' => $data['purchaser_email'],
]
);
}
/**
@@ -105,43 +90,4 @@ class GiftVoucherCheckoutService
return $tier;
}
protected function ensureCustomerId(string $email): string
{
$payload = ['email' => $email];
try {
$response = $this->client->post('/customers', $payload);
} catch (PaddleException $exception) {
$customerId = $this->resolveExistingCustomerId($email, $exception);
if ($customerId) {
return $customerId;
}
throw $exception;
}
$customerId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
if (! $customerId) {
throw new PaddleException('Failed to create Paddle customer.');
}
return $customerId;
}
protected function resolveExistingCustomerId(string $email, PaddleException $exception): ?string
{
if ($exception->status() !== 409 || Arr::get($exception->context(), 'error.code') !== 'customer_already_exists') {
return null;
}
$response = $this->client->get('/customers', [
'email' => $email,
'per_page' => 1,
]);
return Arr::get($response, 'data.0.id') ?? Arr::get($response, 'data.0.customer_id');
}
}

View File

@@ -5,12 +5,11 @@ namespace App\Services\GiftVouchers;
use App\Enums\CouponStatus;
use App\Enums\CouponType;
use App\Jobs\NotifyGiftVoucherReminder;
use App\Jobs\SyncCouponToPaddle;
use App\Mail\GiftVoucherIssued;
use App\Models\Coupon;
use App\Models\GiftVoucher;
use App\Models\Package;
use App\Services\Paddle\PaddleTransactionService;
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
@@ -21,15 +20,15 @@ use Illuminate\Validation\ValidationException;
class GiftVoucherService
{
public function __construct(private readonly PaddleTransactionService $transactions) {}
public function __construct(private readonly LemonSqueezyOrderService $orders) {}
/**
* Create a voucher from a Paddle transaction payload.
* Create a voucher from a Lemon Squeezy order payload.
*/
public function issueFromPaddle(array $payload): GiftVoucher
public function issueFromLemonSqueezy(array $payload): GiftVoucher
{
$metadata = $this->extractCustomData($payload);
$priceId = $this->resolvePriceId($payload);
$variantId = $this->resolveVariantId($payload);
$amount = $this->resolveAmount($payload);
$currency = Str::upper($this->resolveCurrency($payload));
$locale = $metadata['app_locale'] ?? app()->getLocale();
@@ -37,9 +36,10 @@ class GiftVoucherService
$expiresAt = now()->addYears((int) config('gift-vouchers.default_valid_years', 5));
if (! empty($payload['id'])) {
$orderId = data_get($payload, 'data.id');
if ($orderId) {
$existing = GiftVoucher::query()
->where('paddle_transaction_id', $payload['id'])
->where('lemonsqueezy_order_id', $orderId)
->first();
}
@@ -47,19 +47,19 @@ class GiftVoucherService
$voucher = GiftVoucher::query()->updateOrCreate(
[
'paddle_transaction_id' => $payload['id'] ?? null,
'lemonsqueezy_order_id' => $orderId,
],
[
'code' => $metadata['gift_code'] ?? $this->generateCode(),
'amount' => $amount,
'currency' => $currency,
'status' => GiftVoucher::STATUS_ISSUED,
'purchaser_email' => $metadata['purchaser_email'] ?? Arr::get($payload, 'customer.email'),
'purchaser_email' => $metadata['purchaser_email'] ?? data_get($payload, 'data.attributes.user_email'),
'recipient_email' => $metadata['recipient_email'] ?? null,
'recipient_name' => $metadata['recipient_name'] ?? null,
'message' => $metadata['message'] ?? null,
'paddle_checkout_id' => $payload['checkout_id'] ?? Arr::get($payload, 'details.checkout_id'),
'paddle_price_id' => $priceId,
'lemonsqueezy_checkout_id' => data_get($payload, 'data.attributes.checkout_id'),
'lemonsqueezy_variant_id' => $variantId,
'metadata' => $mergedMetadata,
'expires_at' => $expiresAt,
'refunded_at' => null,
@@ -70,7 +70,6 @@ class GiftVoucherService
if (! $voucher->coupon_id) {
$coupon = $this->createCouponForVoucher($voucher);
$voucher->forceFill(['coupon_id' => $coupon->id])->save();
SyncCouponToPaddle::dispatch($coupon);
}
$notificationsSent = (bool) Arr::get($voucher->metadata ?? [], 'notifications_sent', false);
@@ -128,13 +127,13 @@ class GiftVoucherService
]);
}
if (! $voucher->paddle_transaction_id) {
if (! $voucher->lemonsqueezy_order_id) {
throw ValidationException::withMessages([
'voucher' => __('Missing Paddle transaction for refund.'),
'voucher' => __('Missing Lemon Squeezy order for refund.'),
]);
}
$response = $this->transactions->refund($voucher->paddle_transaction_id, array_filter([
$response = $this->orders->refund($voucher->lemonsqueezy_order_id, array_filter([
'reason' => $reason,
]));
@@ -172,6 +171,7 @@ class GiftVoucherService
'description' => 'Geschenkgutschein '.number_format((float) $voucher->amount, 2).' '.$voucher->currency.' für Endkunden-Pakete.',
'starts_at' => now(),
'ends_at' => $voucher->expires_at,
'lemonsqueezy_discount_id' => $voucher->code,
]);
if ($packages->isNotEmpty()) {
@@ -187,41 +187,32 @@ class GiftVoucherService
return Package::query()
->whereIn('type', $types)
->whereNotNull('paddle_price_id')
->whereNotNull('lemonsqueezy_variant_id')
->get(['id']);
}
protected function resolvePriceId(array $payload): ?string
protected function resolveVariantId(array $payload): ?string
{
$metadata = $this->extractCustomData($payload);
if (is_array($metadata) && ! empty($metadata['paddle_price_id'])) {
return $metadata['paddle_price_id'];
if (is_array($metadata) && ! empty($metadata['lemonsqueezy_variant_id'])) {
return $metadata['lemonsqueezy_variant_id'];
}
$items = Arr::get($payload, 'items', Arr::get($payload, 'details.items', []));
if (is_array($items) && isset($items[0]['price_id'])) {
return $items[0]['price_id'];
}
return $payload['price_id'] ?? null;
return data_get($payload, 'data.attributes.variant_id');
}
protected function resolveAmount(array $payload): float
{
$tiers = Collection::make(config('gift-vouchers.tiers', []))
->keyBy(fn ($tier) => $tier['paddle_price_id'] ?? null);
->keyBy(fn ($tier) => $tier['lemonsqueezy_variant_id'] ?? null);
$priceId = $this->resolvePriceId($payload);
if ($priceId && $tiers->has($priceId)) {
return (float) $tiers->get($priceId)['amount'];
$variantId = $this->resolveVariantId($payload);
if ($variantId && $tiers->has($variantId)) {
return (float) $tiers->get($variantId)['amount'];
}
$amount = Arr::get($payload, 'totals.grand_total.amount')
?? Arr::get($payload, 'totals.grand_total')
?? Arr::get($payload, 'details.totals.grand_total.amount')
?? Arr::get($payload, 'details.totals.grand_total')
?? Arr::get($payload, 'amount');
$amount = data_get($payload, 'data.attributes.total');
if (is_numeric($amount)) {
$value = (float) $amount;
@@ -236,10 +227,7 @@ class GiftVoucherService
protected function resolveCurrency(array $payload): string
{
return $payload['currency_code']
?? Arr::get($payload, 'details.totals.currency_code')
?? Arr::get($payload, 'currency')
?? 'EUR';
return (string) (data_get($payload, 'data.attributes.currency') ?? 'EUR');
}
/**
@@ -250,8 +238,16 @@ class GiftVoucherService
{
$customData = [];
if (isset($payload['meta']['custom_data']) && is_array($payload['meta']['custom_data'])) {
$customData = $payload['meta']['custom_data'];
}
if (isset($payload['attributes']['custom_data']) && is_array($payload['attributes']['custom_data'])) {
$customData = array_merge($customData, $payload['attributes']['custom_data']);
}
if (isset($payload['custom_data']) && is_array($payload['custom_data'])) {
$customData = $payload['custom_data'];
$customData = array_merge($customData, $payload['custom_data']);
}
if (isset($payload['customData']) && is_array($payload['customData'])) {