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

@@ -117,14 +117,22 @@ PAYPAL_CLIENT_ID=
PAYPAL_SECRET= PAYPAL_SECRET=
PAYPAL_SANDBOX=true PAYPAL_SANDBOX=true
# Paddle Billing # Lemon Squeezy Billing
PADDLE_SANDBOX=true LEMONSQUEEZY_STORE_ID=284860
PADDLE_API_KEY= LEMONSQUEEZY_API_KEY=
PADDLE_CLIENT_ID= LEMONSQUEEZY_WEBHOOK_SECRET=
PADDLE_WEBHOOK_SECRET= LEMONSQUEEZY_WEBHOOK_EVENTS=
PADDLE_PUBLIC_KEY= LEMONSQUEEZY_TEST_MODE=false
PADDLE_BASE_URL= LEMONSQUEEZY_BASE_URL=https://api.lemonsqueezy.com/v1
PADDLE_CONSOLE_URL= LEMONSQUEEZY_GIFT_VARIANT_STARTER=
LEMONSQUEEZY_GIFT_VARIANT_STARTER_USD=
LEMONSQUEEZY_GIFT_VARIANT_STARTER_GBP=
LEMONSQUEEZY_GIFT_VARIANT_STANDARD=
LEMONSQUEEZY_GIFT_VARIANT_STANDARD_USD=
LEMONSQUEEZY_GIFT_VARIANT_STANDARD_GBP=
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM=
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_USD=
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_GBP=
# Sanctum / SPA auth # Sanctum / SPA auth
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000 SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000

View File

@@ -3599,6 +3599,8 @@ var tokens3 = {
// ... existing radius tokens ... // ... existing radius tokens ...
card: 16, card: 16,
tile: 14, tile: 14,
bento: 24,
bentoLg: 32,
pill: 999 pill: 999
} }
// ... // ...

View File

@@ -503083,6 +503083,20 @@
"val": 14, "val": 14,
"variable": "var(--c-radius-tile)" "variable": "var(--c-radius-tile)"
}, },
"bento": {
"isVar": true,
"key": "$bento",
"name": "c-radius-bento",
"val": 24,
"variable": "var(--c-radius-bento)"
},
"bentoLg": {
"isVar": true,
"key": "$bentoLg",
"name": "c-radius-bentoLg",
"val": 32,
"variable": "var(--c-radius-bentoLg)"
},
"pill": { "pill": {
"isVar": true, "isVar": true,
"key": "$pill", "key": "$pill",
@@ -505293,6 +505307,20 @@
"val": "hsl(53, 92.0%, 50.0%)", "val": "hsl(53, 92.0%, 50.0%)",
"variable": "var(--c-yellow9Light)" "variable": "var(--c-yellow9Light)"
}, },
"$radius.bento": {
"isVar": true,
"key": "$bento",
"name": "c-radius-bento",
"val": 24,
"variable": "var(--c-radius-bento)"
},
"$radius.bentoLg": {
"isVar": true,
"key": "$bentoLg",
"name": "c-radius-bentoLg",
"val": 32,
"variable": "var(--c-radius-bentoLg)"
},
"$radius.card": { "$radius.card": {
"isVar": true, "isVar": true,
"key": "$card", "key": "$card",

View File

@@ -27,8 +27,8 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
- Languages/Frameworks: PHP 8.2+ (Laravel 12), TypeScript/JavaScript (React 19/Vite 7/Tailwind 4), Filament 4. - Languages/Frameworks: PHP 8.2+ (Laravel 12), TypeScript/JavaScript (React 19/Vite 7/Tailwind 4), Filament 4.
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev), Playwright, Vitest, TypeScript. - Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev), Playwright, Vitest, TypeScript.
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Paddle API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds. - Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Lemon Squeezy API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds.
- Payment Systems: Paddle (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use). - Payment Systems: Lemon Squeezy (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use).
- PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync. - PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync.
## Repo Structure (high-level) ## Repo Structure (high-level)
@@ -61,7 +61,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
#### Billing & Packages #### Billing & Packages
- package:check-status — check event package status. - package:check-status — check event package status.
- packages:migrate-legacy — migrate legacy package purchases. - packages:migrate-legacy — migrate legacy package purchases.
- paddle:sync-packages — sync packages with Paddle (push/pull/queue/dry-run). - lemonsqueezy:sync-packages — sync packages with Lemon Squeezy (push/pull/queue/dry-run).
- coupons:export — export coupon redemptions. - coupons:export — export coupon redemptions.
- checkout:send-reminders — send abandoned checkout reminders (dry-run supported). - checkout:send-reminders — send abandoned checkout reminders (dry-run supported).
@@ -96,7 +96,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
- metrics:package-limits — inspect/reset package limit metrics (routes/console.php). - metrics:package-limits — inspect/reset package limit metrics (routes/console.php).
- inspire — inspiring quote (routes/console.php). - inspire — inspiring quote (routes/console.php).
- Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/archive/prp/03-api.md. - Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/archive/prp/03-api.md.
- Payment Integration: Paddle webhooks, RevenueCat mobile subscriptions. - Payment Integration: Lemon Squeezy webhooks, RevenueCat mobile subscriptions.
## PWA Architecture ## PWA Architecture
- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required). - Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required).

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Console\Commands;
use App\Services\LemonSqueezy\LemonSqueezyClient;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
class LemonSqueezyRegisterWebhooks extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'lemonsqueezy:webhooks:register
{--url= : Destination URL for Lemon Squeezy webhooks}
{--events=* : Override event types to subscribe}
{--secret= : Override the webhook signing secret}
{--test-mode : Register the webhook in test mode}
{--dry-run : Output payload without creating the destination}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Register Lemon Squeezy webhook notification settings.';
/**
* Execute the console command.
*/
public function handle(LemonSqueezyClient $client): int
{
$destination = (string) ($this->option('url') ?: $this->defaultWebhookUrl());
if ($destination === '') {
$this->error('Webhook destination URL is required. Use --url=...');
return self::FAILURE;
}
$events = collect((array) $this->option('events'))
->filter()
->map(fn ($event) => trim((string) $event))
->filter()
->values()
->all();
if ($events === []) {
$events = config('lemonsqueezy.webhook_events', []);
}
if ($events === [] || ! is_array($events)) {
$this->error('No webhook events configured. Set config(lemonsqueezy.webhook_events) or pass --events.');
return self::FAILURE;
}
$secret = (string) ($this->option('secret') ?: config('lemonsqueezy.webhook_secret'));
if ($secret === '') {
$this->error('Webhook signing secret is required. Set LEMONSQUEEZY_WEBHOOK_SECRET or pass --secret.');
return self::FAILURE;
}
$storeId = (string) config('lemonsqueezy.store_id');
if ($storeId === '') {
$this->error('Lemon Squeezy store id is required. Set LEMONSQUEEZY_STORE_ID.');
return self::FAILURE;
}
$testMode = (bool) $this->option('test-mode') || (bool) config('lemonsqueezy.test_mode', false);
$attributes = array_filter([
'url' => $destination,
'events' => $events,
'secret' => $secret,
'test_mode' => $testMode ? true : null,
], static fn ($value) => $value !== null && $value !== '');
$payload = [
'data' => [
'type' => 'webhooks',
'attributes' => $attributes,
'relationships' => [
'store' => [
'data' => [
'type' => 'stores',
'id' => $storeId,
],
],
],
],
];
if ((bool) $this->option('dry-run')) {
$this->line(json_encode($payload, JSON_PRETTY_PRINT));
return self::SUCCESS;
}
$response = $client->post('/webhooks', $payload);
$data = Arr::get($response, 'data', $response);
$id = Arr::get($data, 'id');
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy webhook registered', [
'webhook_id' => $id,
'destination' => $destination,
'test_mode' => $testMode,
]);
$this->info('Lemon Squeezy webhook registered.');
if ($id) {
$this->line('ID: '.$id);
}
return self::SUCCESS;
}
protected function defaultWebhookUrl(): string
{
$base = rtrim((string) config('app.url'), '/');
return $base !== '' ? $base.'/lemonsqueezy/webhook' : '';
}
}

View File

@@ -2,23 +2,23 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Jobs\PullPackageFromPaddle; use App\Jobs\PullPackageFromLemonSqueezy;
use App\Jobs\SyncPackageToPaddle; use App\Jobs\SyncPackageToLemonSqueezy;
use App\Models\Package; use App\Models\Package;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class PaddleSyncPackages extends Command class LemonSqueezySyncPackages extends Command
{ {
protected $signature = 'paddle:sync-packages protected $signature = 'lemonsqueezy:sync-packages
{--package=* : Limit sync to the given package IDs or slugs} {--package=* : Limit sync to the given package IDs or slugs}
{--dry-run : Generate payload snapshots without calling Paddle} {--dry-run : Generate payload snapshots without calling Lemon Squeezy}
{--pull : Fetch remote Paddle state instead of pushing local changes} {--pull : Fetch remote Lemon Squeezy state instead of pushing local changes}
{--allow-unmapped : Allow sync when packages are missing Paddle product/price IDs} {--allow-unmapped : Allow sync when packages are missing Lemon Squeezy product/variant IDs}
{--queue : Dispatch jobs onto the queue instead of running synchronously}'; {--queue : Dispatch jobs onto the queue instead of running synchronously}';
protected $description = 'Synchronise local packages with Paddle products and prices.'; protected $description = 'Synchronise local packages with Lemon Squeezy products and variants.';
public function handle(): int public function handle(): int
{ {
@@ -52,7 +52,7 @@ class PaddleSyncPackages extends Command
}); });
$this->info(sprintf( $this->info(sprintf(
'Queued %d package %s for Paddle %s.', 'Queued %d package %s for Lemon Squeezy %s.',
$packages->count(), $packages->count(),
Str::plural('entry', $packages->count()), Str::plural('entry', $packages->count()),
$pull ? 'pull' : 'sync' $pull ? 'pull' : 'sync'
@@ -97,22 +97,22 @@ class PaddleSyncPackages extends Command
protected function guardUnmappedPackages(Collection $packages): bool protected function guardUnmappedPackages(Collection $packages): bool
{ {
$unmapped = $packages->filter(fn (Package $package) => blank($package->paddle_product_id) || blank($package->paddle_price_id)); $unmapped = $packages->filter(fn (Package $package) => blank($package->lemonsqueezy_product_id) || blank($package->lemonsqueezy_variant_id));
if ($unmapped->isEmpty()) { if ($unmapped->isEmpty()) {
return true; return true;
} }
$this->error('Unmapped Paddle package IDs detected. Resolve legacy mappings or pass --allow-unmapped.'); $this->error('Unmapped Lemon Squeezy package IDs detected. Resolve mappings or pass --allow-unmapped.');
$this->table( $this->table(
['ID', 'Slug', 'Missing'], ['ID', 'Slug', 'Missing'],
$unmapped->map(function (Package $package): array { $unmapped->map(function (Package $package): array {
$missing = []; $missing = [];
if (blank($package->paddle_product_id)) { if (blank($package->lemonsqueezy_product_id)) {
$missing[] = 'product_id'; $missing[] = 'product_id';
} }
if (blank($package->paddle_price_id)) { if (blank($package->lemonsqueezy_variant_id)) {
$missing[] = 'price_id'; $missing[] = 'variant_id';
} }
return [ return [
@@ -133,26 +133,26 @@ class PaddleSyncPackages extends Command
]; ];
if ($queue) { if ($queue) {
SyncPackageToPaddle::dispatch($package->id, $context); SyncPackageToLemonSqueezy::dispatch($package->id, $context);
$this->line(sprintf('> queued sync for package #%d (%s)', $package->id, $package->slug)); $this->line(sprintf('> queued sync for package #%d (%s)', $package->id, $package->slug));
return; return;
} }
SyncPackageToPaddle::dispatchSync($package->id, $context); SyncPackageToLemonSqueezy::dispatchSync($package->id, $context);
$this->line(sprintf('> synced package #%d (%s)', $package->id, $package->slug)); $this->line(sprintf('> synced package #%d (%s)', $package->id, $package->slug));
} }
protected function dispatchPullJob(Package $package, bool $queue): void protected function dispatchPullJob(Package $package, bool $queue): void
{ {
if ($queue) { if ($queue) {
PullPackageFromPaddle::dispatch($package->id); PullPackageFromLemonSqueezy::dispatch($package->id);
$this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug)); $this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug));
return; return;
} }
PullPackageFromPaddle::dispatchSync($package->id); PullPackageFromLemonSqueezy::dispatchSync($package->id);
$this->line(sprintf('> pulled package #%d (%s)', $package->id, $package->slug)); $this->line(sprintf('> pulled package #%d (%s)', $package->id, $package->slug));
} }
} }

View File

@@ -1,132 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\Paddle\PaddleClient;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
class PaddleRegisterWebhooks extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'paddle:webhooks:register
{--url= : Destination URL for Paddle webhooks}
{--description= : Description for the webhook destination}
{--events=* : Override event types to subscribe}
{--traffic-source=all : platform|simulation|all}
{--include-sensitive : Include sensitive fields in webhook payloads}
{--show-secret : Output the endpoint secret key}
{--dry-run : Output payload without creating the destination}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Register Paddle webhook notification settings.';
/**
* Execute the console command.
*/
public function handle(PaddleClient $client): int
{
$destination = (string) ($this->option('url') ?: $this->defaultWebhookUrl());
if ($destination === '') {
$this->error('Webhook destination URL is required. Use --url=...');
return self::FAILURE;
}
$events = collect((array) $this->option('events'))
->filter()
->map(fn ($event) => trim((string) $event))
->filter()
->values()
->all();
if ($events === []) {
$events = config('paddle.webhook_events', []);
}
if ($events === [] || ! is_array($events)) {
$this->error('No webhook events configured. Set config(paddle.webhook_events) or pass --events.');
return self::FAILURE;
}
$trafficSource = (string) $this->option('traffic-source');
$allowedSources = ['platform', 'simulation', 'all'];
if (! in_array($trafficSource, $allowedSources, true)) {
$this->error(sprintf('Invalid traffic source. Use one of: %s', implode(', ', $allowedSources)));
return self::FAILURE;
}
$payload = [
'type' => 'url',
'destination' => $destination,
'description' => $this->resolveDescription(),
'subscribed_events' => $events,
'traffic_source' => $trafficSource,
'include_sensitive_fields' => (bool) $this->option('include-sensitive'),
];
if ((bool) $this->option('dry-run')) {
$this->line(json_encode($payload, JSON_PRETTY_PRINT));
return self::SUCCESS;
}
$response = $client->post('/notification-settings', $payload);
$data = Arr::get($response, 'data', $response);
$id = Arr::get($data, 'id');
$secret = Arr::get($data, 'endpoint_secret_key');
Log::channel('paddle-sync')->info('Paddle webhook registered', [
'notification_setting_id' => $id,
'destination' => $destination,
'traffic_source' => $trafficSource,
]);
$this->info('Paddle webhook registered.');
if ($id) {
$this->line('ID: '.$id);
}
if ($secret && $this->option('show-secret')) {
$this->line('Secret: '.$secret);
} elseif ($secret) {
$this->line('Secret returned (hidden). Use --show-secret to display.');
}
return self::SUCCESS;
}
protected function defaultWebhookUrl(): string
{
$base = rtrim((string) config('app.url'), '/');
return $base !== '' ? $base.'/paddle/webhook' : '';
}
protected function resolveDescription(): string
{
$description = (string) $this->option('description');
if ($description !== '') {
return $description;
}
$environment = (string) config('paddle.environment', 'production');
return sprintf('Fotospiel Paddle webhooks (%s)', $environment);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\Pages;
use App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\TenantLemonSqueezyHealthResource;
use Filament\Resources\Pages\ListRecords;
class ListTenantLemonSqueezyHealths extends ListRecords
{
protected static string $resource = TenantLemonSqueezyHealthResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -1,8 +1,8 @@
<?php <?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables; namespace App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\Tables;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource; use App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\TenantLemonSqueezyHealthResource;
use App\Models\CheckoutSession; use App\Models\CheckoutSession;
use App\Models\Tenant; use App\Models\Tenant;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
@@ -15,7 +15,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class TenantPaddleHealthTable class TenantLemonSqueezyHealthTable
{ {
private const FAILED_SYNC_STATUSES = ['failed', 'pull-failed']; private const FAILED_SYNC_STATUSES = ['failed', 'pull-failed'];
@@ -35,8 +35,8 @@ class TenantPaddleHealthTable
->label(__('admin.tenants.fields.contact_email')) ->label(__('admin.tenants.fields.contact_email'))
->searchable() ->searchable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_customer_id') TextColumn::make('lemonsqueezy_customer_id')
->label('Paddle customer') ->label('Lemon Squeezy customer')
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->copyable() ->copyable()
->formatStateUsing(fn (?string $state) => $state ?: '—'), ->formatStateUsing(fn (?string $state) => $state ?: '—'),
@@ -56,27 +56,27 @@ class TenantPaddleHealthTable
->badge() ->badge()
->color(fn (string $state) => $state === '—' ? 'gray' : 'success') ->color(fn (string $state) => $state === '—' ? 'gray' : 'success')
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_subscription_id') TextColumn::make('lemonsqueezy_subscription_id')
->label('Paddle subscription') ->label('Lemon Squeezy subscription')
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->copyable() ->copyable()
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->paddle_subscription_id) ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->lemonsqueezy_subscription_id)
->formatStateUsing(fn (?string $state) => $state ?: '—'), ->formatStateUsing(fn (?string $state) => $state ?: '—'),
IconColumn::make('missing_paddle_subscription') IconColumn::make('missing_lemonsqueezy_subscription')
->label('Missing Paddle subscription') ->label('Missing Lemon Squeezy subscription')
->boolean() ->boolean()
->getStateUsing(fn (Tenant $record) => self::missingPaddleSubscription($record)), ->getStateUsing(fn (Tenant $record) => self::missingLemonSqueezySubscription($record)),
IconColumn::make('status_mismatch') IconColumn::make('status_mismatch')
->label('Status mismatch') ->label('Status mismatch')
->boolean() ->boolean()
->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)), ->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)),
TextColumn::make('paddle_customer_duplicates') TextColumn::make('lemonsqueezy_customer_duplicates')
->label('Paddle duplicates') ->label('Lemon Squeezy duplicates')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn (?int $state) => $state && $state > 1 ? (string) $state : '—'), ->formatStateUsing(fn (?int $state) => $state && $state > 1 ? (string) $state : '—'),
TextColumn::make('paddle_sync_status') TextColumn::make('lemonsqueezy_sync_status')
->label('Paddle sync') ->label('Lemon Squeezy sync')
->badge() ->badge()
->color(fn (?string $state) => match ($state) { ->color(fn (?string $state) => match ($state) {
'synced' => 'success', 'synced' => 'success',
@@ -87,101 +87,101 @@ class TenantPaddleHealthTable
default => 'gray', default => 'gray',
}) })
->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—') ->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_sync_status) ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->lemonsqueezy_sync_status)
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_synced_at') TextColumn::make('lemonsqueezy_synced_at')
->label('Paddle synced') ->label('Lemon Squeezy synced')
->badge() ->badge()
->color(fn ($state) => self::syncAgeColor($state)) ->color(fn ($state) => self::syncAgeColor($state))
->formatStateUsing(fn ($state) => $state?->diffForHumans() ?? '—') ->formatStateUsing(fn ($state) => $state?->diffForHumans() ?? '—')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_synced_at), ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->lemonsqueezy_synced_at),
TextColumn::make('last_paddle_transaction_at') TextColumn::make('last_lemonsqueezy_transaction_at')
->label('Last Paddle tx') ->label('Last Lemon Squeezy tx')
->badge() ->badge()
->color(fn (?Carbon $state) => self::transactionAgeColor($state)) ->color(fn (?Carbon $state) => self::transactionAgeColor($state))
->getStateUsing(fn (Tenant $record) => $record->last_paddle_transaction_at ->getStateUsing(fn (Tenant $record) => $record->last_lemonsqueezy_transaction_at
? Carbon::parse($record->last_paddle_transaction_at) ? Carbon::parse($record->last_lemonsqueezy_transaction_at)
: null) : null)
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—') ->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_transaction_count_window') TextColumn::make('lemonsqueezy_transaction_count_window')
->label('Paddle tx (30d)') ->label('Lemon Squeezy tx (30d)')
->default('0') ->default('0')
->sortable() ->sortable()
->toggleable(), ->toggleable(),
TextColumn::make('paddle_transaction_total_window') TextColumn::make('lemonsqueezy_transaction_total_window')
->label('Paddle total (30d)') ->label('Lemon Squeezy total (30d)')
->default(0) ->default(0)
->money('EUR') ->money('EUR')
->sortable() ->sortable()
->toggleable(), ->toggleable(),
TextColumn::make('paddle_refund_count_window') TextColumn::make('lemonsqueezy_refund_count_window')
->label('Refunds (30d)') ->label('Refunds (30d)')
->badge() ->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray') ->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
->default('0') ->default('0')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_refund_total_window') TextColumn::make('lemonsqueezy_refund_total_window')
->label('Refund total (30d)') ->label('Refund total (30d)')
->default(0) ->default(0)
->money('EUR') ->money('EUR')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_checkout_requires_action_count') TextColumn::make('lemonsqueezy_checkout_requires_action_count')
->label('Checkout action required') ->label('Checkout action required')
->badge() ->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray') ->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
->default('0') ->default('0')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_checkout_processing_count') TextColumn::make('lemonsqueezy_checkout_processing_count')
->label('Checkout processing') ->label('Checkout processing')
->badge() ->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray') ->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
->default('0') ->default('0')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_checkout_expired_count') TextColumn::make('lemonsqueezy_checkout_expired_count')
->label('Checkout expired') ->label('Checkout expired')
->badge() ->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray') ->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
->default('0') ->default('0')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_transaction_count') TextColumn::make('lemonsqueezy_transaction_count')
->label('Paddle tx (all)') ->label('Lemon Squeezy tx (all)')
->default('0') ->default('0')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_transaction_total') TextColumn::make('lemonsqueezy_transaction_total')
->label('Paddle total (all)') ->label('Lemon Squeezy total (all)')
->default(0) ->default(0)
->money('EUR') ->money('EUR')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
]) ])
->filters([ ->filters([
Filter::make('missing_paddle_customer') Filter::make('missing_lemonsqueezy_customer')
->label('Missing Paddle customer') ->label('Missing Lemon Squeezy customer')
->indicator('Missing Paddle customer') ->indicator('Missing Lemon Squeezy customer')
->query(fn (Builder $query) => $query->whereNull('paddle_customer_id')), ->query(fn (Builder $query) => $query->whereNull('lemonsqueezy_customer_id')),
Filter::make('missing_paddle_subscription') Filter::make('missing_lemonsqueezy_subscription')
->label('Missing Paddle subscription') ->label('Missing Lemon Squeezy subscription')
->indicator('Missing Paddle subscription') ->indicator('Missing Lemon Squeezy subscription')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', fn (Builder $query) => $query ->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', fn (Builder $query) => $query
->where('active', true) ->where('active', true)
->whereNull('paddle_subscription_id'))), ->whereNull('lemonsqueezy_subscription_id'))),
Filter::make('duplicate_paddle_customer') Filter::make('duplicate_lemonsqueezy_customer')
->label('Duplicate Paddle customer') ->label('Duplicate Lemon Squeezy customer')
->indicator('Duplicate Paddle customer') ->indicator('Duplicate Lemon Squeezy customer')
->query(fn (Builder $query) => $query ->query(fn (Builder $query) => $query
->whereNotNull('paddle_customer_id') ->whereNotNull('lemonsqueezy_customer_id')
->whereIn('paddle_customer_id', function ($subquery) { ->whereIn('lemonsqueezy_customer_id', function ($subquery) {
$subquery->select('paddle_customer_id') $subquery->select('lemonsqueezy_customer_id')
->from('tenants') ->from('tenants')
->whereNotNull('paddle_customer_id') ->whereNotNull('lemonsqueezy_customer_id')
->groupBy('paddle_customer_id') ->groupBy('lemonsqueezy_customer_id')
->havingRaw('count(*) > 1'); ->havingRaw('count(*) > 1');
})), })),
Filter::make('status_mismatch') Filter::make('status_mismatch')
@@ -205,39 +205,39 @@ class TenantPaddleHealthTable
->where('is_suspended', false) ->where('is_suspended', false)
->whereNull('pending_deletion_at') ->whereNull('pending_deletion_at')
->whereNull('anonymized_at')), ->whereNull('anonymized_at')),
Filter::make('paddle_sync_failed') Filter::make('lemonsqueezy_sync_failed')
->label('Paddle sync failed') ->label('Lemon Squeezy sync failed')
->indicator('Paddle sync failed') ->indicator('Lemon Squeezy sync failed')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query ->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereIn('paddle_sync_status', self::FAILED_SYNC_STATUSES))), ->whereIn('lemonsqueezy_sync_status', self::FAILED_SYNC_STATUSES))),
Filter::make('paddle_sync_stale') Filter::make('lemonsqueezy_sync_stale')
->label('Paddle sync stale') ->label('Lemon Squeezy sync stale')
->indicator('Paddle sync stale') ->indicator('Lemon Squeezy sync stale')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query ->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereNotNull('paddle_synced_at') ->whereNotNull('lemonsqueezy_synced_at')
->where('paddle_synced_at', '<', now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS)))), ->where('lemonsqueezy_synced_at', '<', now()->subDays(TenantLemonSqueezyHealthResource::STALE_SYNC_DAYS)))),
Filter::make('paddle_sync_missing') Filter::make('lemonsqueezy_sync_missing')
->label('Missing Paddle sync timestamp') ->label('Missing Lemon Squeezy sync timestamp')
->indicator('Missing Paddle sync timestamp') ->indicator('Missing Lemon Squeezy sync timestamp')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query ->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereNull('paddle_synced_at'))), ->whereNull('lemonsqueezy_synced_at'))),
Filter::make('paddle_transaction_stale') Filter::make('lemonsqueezy_transaction_stale')
->label('Stale Paddle transactions') ->label('Stale Lemon Squeezy transactions')
->indicator('Stale Paddle transactions') ->indicator('Stale Lemon Squeezy transactions')
->query(function (Builder $query): Builder { ->query(function (Builder $query): Builder {
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS); $cutoff = now()->subDays(TenantLemonSqueezyHealthResource::TRANSACTION_WINDOW_DAYS);
return $query return $query
->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'paddle')) ->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'lemonsqueezy'))
->whereDoesntHave('purchases', fn (Builder $query) => $query ->whereDoesntHave('purchases', fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('purchased_at', '>=', $cutoff)); ->where('purchased_at', '>=', $cutoff));
}), }),
Filter::make('checkout_attention') Filter::make('checkout_attention')
->label('Checkout attention') ->label('Checkout attention')
->indicator('Checkout attention') ->indicator('Checkout attention')
->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) { ->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) {
$query->where('provider', 'paddle') $query->where('provider', 'lemonsqueezy')
->where(function (Builder $query) { ->where(function (Builder $query) {
$query->whereIn('status', [ $query->whereIn('status', [
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION, CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
@@ -274,10 +274,10 @@ class TenantPaddleHealthTable
return $query; return $query;
} }
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS); $cutoff = now()->subDays(TenantLemonSqueezyHealthResource::TRANSACTION_WINDOW_DAYS);
return $query->whereHas('purchases', fn (Builder $query) => $query return $query->whereHas('purchases', fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('refunded', true) ->where('refunded', true)
->where('purchased_at', '>=', $cutoff), '>=', $min); ->where('purchased_at', '>=', $cutoff), '>=', $min);
}), }),
@@ -314,11 +314,11 @@ class TenantPaddleHealthTable
return false; return false;
} }
private static function missingPaddleSubscription(Tenant $record): bool private static function missingLemonSqueezySubscription(Tenant $record): bool
{ {
$package = $record->activeResellerPackage; $package = $record->activeResellerPackage;
return $package && $package->active && ! $package->paddle_subscription_id; return $package && $package->active && ! $package->lemonsqueezy_subscription_id;
} }
private static function applyStatusMismatchFilter(Builder $query): Builder private static function applyStatusMismatchFilter(Builder $query): Builder
@@ -344,7 +344,7 @@ class TenantPaddleHealthTable
return 'gray'; return 'gray';
} }
if ($state->lt(now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS))) { if ($state->lt(now()->subDays(TenantLemonSqueezyHealthResource::STALE_SYNC_DAYS))) {
return 'danger'; return 'danger';
} }
@@ -357,7 +357,7 @@ class TenantPaddleHealthTable
return 'gray'; return 'gray';
} }
if ($state->lt(now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS))) { if ($state->lt(now()->subDays(TenantLemonSqueezyHealthResource::TRANSACTION_WINDOW_DAYS))) {
return 'danger'; return 'danger';
} }

View File

@@ -1,10 +1,10 @@
<?php <?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths; namespace App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths;
use App\Filament\Clusters\DailyOps\DailyOpsCluster; use App\Filament\Clusters\DailyOps\DailyOpsCluster;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages\ListTenantPaddleHealths; use App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\Pages\ListTenantLemonSqueezyHealths;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables\TenantPaddleHealthTable; use App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\Tables\TenantLemonSqueezyHealthTable;
use App\Models\CheckoutSession; use App\Models\CheckoutSession;
use App\Models\Tenant; use App\Models\Tenant;
use BackedEnum; use BackedEnum;
@@ -13,7 +13,7 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use UnitEnum; use UnitEnum;
class TenantPaddleHealthResource extends Resource class TenantLemonSqueezyHealthResource extends Resource
{ {
public const STALE_SYNC_DAYS = 30; public const STALE_SYNC_DAYS = 30;
@@ -25,13 +25,13 @@ class TenantPaddleHealthResource extends Resource
protected static ?string $cluster = DailyOpsCluster::class; protected static ?string $cluster = DailyOpsCluster::class;
protected static ?string $slug = 'paddle-health'; protected static ?string $slug = 'lemonsqueezy-health';
protected static ?int $navigationSort = 20; protected static ?int $navigationSort = 20;
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return TenantPaddleHealthTable::configure($table); return TenantLemonSqueezyHealthTable::configure($table);
} }
public static function canCreate(): bool public static function canCreate(): bool
@@ -41,7 +41,7 @@ class TenantPaddleHealthResource extends Resource
public static function getNavigationLabel(): string public static function getNavigationLabel(): string
{ {
return __('admin.paddle_health.navigation.label'); return __('admin.lemonsqueezy_health.navigation.label');
} }
public static function getNavigationGroup(): UnitEnum|string|null public static function getNavigationGroup(): UnitEnum|string|null
@@ -57,31 +57,31 @@ class TenantPaddleHealthResource extends Resource
->with(['activeResellerPackage.package']) ->with(['activeResellerPackage.package'])
->withExists('activeResellerPackage as has_active_reseller_package') ->withExists('activeResellerPackage as has_active_reseller_package')
->addSelect([ ->addSelect([
'paddle_customer_duplicates' => Tenant::query() 'lemonsqueezy_customer_duplicates' => Tenant::query()
->selectRaw('count(*)') ->selectRaw('count(*)')
->whereColumn('paddle_customer_id', 'tenants.paddle_customer_id') ->whereColumn('lemonsqueezy_customer_id', 'tenants.lemonsqueezy_customer_id')
->whereNotNull('paddle_customer_id'), ->whereNotNull('lemonsqueezy_customer_id'),
]) ])
->withCount([ ->withCount([
'purchases as paddle_transaction_count' => fn (Builder $query) => $query 'purchases as lemonsqueezy_transaction_count' => fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('refunded', false), ->where('refunded', false),
'purchases as paddle_transaction_count_window' => fn (Builder $query) => $query 'purchases as lemonsqueezy_transaction_count_window' => fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('refunded', false) ->where('refunded', false)
->where('purchased_at', '>=', $windowStart), ->where('purchased_at', '>=', $windowStart),
'purchases as paddle_refund_count_window' => fn (Builder $query) => $query 'purchases as lemonsqueezy_refund_count_window' => fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('refunded', true) ->where('refunded', true)
->where('purchased_at', '>=', $windowStart), ->where('purchased_at', '>=', $windowStart),
'checkoutSessions as paddle_checkout_requires_action_count' => fn (Builder $query) => $query 'checkoutSessions as lemonsqueezy_checkout_requires_action_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE) ->where('provider', CheckoutSession::PROVIDER_LEMONSQUEEZY)
->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION), ->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION),
'checkoutSessions as paddle_checkout_processing_count' => fn (Builder $query) => $query 'checkoutSessions as lemonsqueezy_checkout_processing_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE) ->where('provider', CheckoutSession::PROVIDER_LEMONSQUEEZY)
->where('status', CheckoutSession::STATUS_PROCESSING), ->where('status', CheckoutSession::STATUS_PROCESSING),
'checkoutSessions as paddle_checkout_expired_count' => fn (Builder $query) => $query 'checkoutSessions as lemonsqueezy_checkout_expired_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE) ->where('provider', CheckoutSession::PROVIDER_LEMONSQUEEZY)
->whereNotIn('status', [ ->whereNotIn('status', [
CheckoutSession::STATUS_COMPLETED, CheckoutSession::STATUS_COMPLETED,
CheckoutSession::STATUS_CANCELLED, CheckoutSession::STATUS_CANCELLED,
@@ -90,32 +90,32 @@ class TenantPaddleHealthResource extends Resource
->where('expires_at', '<', now()), ->where('expires_at', '<', now()),
]) ])
->withSum([ ->withSum([
'purchases as paddle_transaction_total' => fn (Builder $query) => $query 'purchases as lemonsqueezy_transaction_total' => fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('refunded', false), ->where('refunded', false),
], 'price') ], 'price')
->withSum([ ->withSum([
'purchases as paddle_transaction_total_window' => fn (Builder $query) => $query 'purchases as lemonsqueezy_transaction_total_window' => fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('refunded', false) ->where('refunded', false)
->where('purchased_at', '>=', $windowStart), ->where('purchased_at', '>=', $windowStart),
], 'price') ], 'price')
->withSum([ ->withSum([
'purchases as paddle_refund_total_window' => fn (Builder $query) => $query 'purchases as lemonsqueezy_refund_total_window' => fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('refunded', true) ->where('refunded', true)
->where('purchased_at', '>=', $windowStart), ->where('purchased_at', '>=', $windowStart),
], 'price') ], 'price')
->withMax([ ->withMax([
'purchases as last_paddle_transaction_at' => fn (Builder $query) => $query 'purchases as last_lemonsqueezy_transaction_at' => fn (Builder $query) => $query
->where('provider', 'paddle'), ->where('provider', 'lemonsqueezy'),
], 'purchased_at'); ], 'purchased_at');
} }
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => ListTenantPaddleHealths::route('/'), 'index' => ListTenantLemonSqueezyHealths::route('/'),
]; ];
} }
} }

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
use Filament\Resources\Pages\ListRecords;
class ListTenantPaddleHealths extends ListRecords
{
protected static string $resource = TenantPaddleHealthResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Pages;
use App\Filament\Resources\Coupons\CouponResource; use App\Filament\Resources\Coupons\CouponResource;
use App\Filament\Resources\Pages\AuditedCreateRecord; use App\Filament\Resources\Pages\AuditedCreateRecord;
use App\Jobs\SyncCouponToPaddle; use App\Jobs\SyncCouponToLemonSqueezy;
class CreateCoupon extends AuditedCreateRecord class CreateCoupon extends AuditedCreateRecord
{ {
@@ -14,6 +14,6 @@ class CreateCoupon extends AuditedCreateRecord
{ {
parent::afterCreate(); parent::afterCreate();
SyncCouponToPaddle::dispatch($this->record); SyncCouponToLemonSqueezy::dispatch($this->record);
} }
} }

View File

@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Pages;
use App\Filament\Resources\Coupons\CouponResource; use App\Filament\Resources\Coupons\CouponResource;
use App\Filament\Resources\Pages\AuditedEditRecord; use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Jobs\SyncCouponToPaddle; use App\Jobs\SyncCouponToLemonSqueezy;
use App\Services\Audit\SuperAdminAuditLogger; use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction; use Filament\Actions\ForceDeleteAction;
@@ -27,7 +27,7 @@ class EditCoupon extends AuditedEditRecord
source: static::class source: static::class
); );
SyncCouponToPaddle::dispatch($record, true); SyncCouponToLemonSqueezy::dispatch($record, true);
}), }),
ForceDeleteAction::make() ForceDeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation( ->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
@@ -48,6 +48,6 @@ class EditCoupon extends AuditedEditRecord
{ {
parent::afterSave(); parent::afterSave();
SyncCouponToPaddle::dispatch($this->record); SyncCouponToLemonSqueezy::dispatch($this->record);
} }
} }

View File

@@ -21,7 +21,7 @@ class RedemptionsRelationManager extends RelationManager
public function table(Table $table): Table public function table(Table $table): Table
{ {
return $table return $table
->recordTitleAttribute('paddle_transaction_id') ->recordTitleAttribute('lemonsqueezy_order_id')
->columns([ ->columns([
TextColumn::make('tenant.name') TextColumn::make('tenant.name')
->label(__('Tenant')) ->label(__('Tenant'))
@@ -65,7 +65,7 @@ class RedemptionsRelationManager extends RelationManager
'failed' => 'danger', 'failed' => 'danger',
default => 'warning', default => 'warning',
}), }),
TextColumn::make('paddle_transaction_id') TextColumn::make('lemonsqueezy_order_id')
->label(__('Transaction')) ->label(__('Transaction'))
->copyable() ->copyable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),

View File

@@ -123,22 +123,22 @@ class CouponForm
->nullable() ->nullable()
->columnSpanFull(), ->columnSpanFull(),
]), ]),
Section::make(__('Paddle sync')) Section::make(__('Lemon Squeezy sync'))
->columns(2) ->columns(2)
->schema([ ->schema([
Select::make('paddle_mode') Select::make('lemonsqueezy_mode')
->label(__('Paddle mode')) ->label(__('Lemon Squeezy mode'))
->options([ ->options([
'standard' => __('Standard'), 'standard' => __('Standard'),
'custom' => __('Custom (one-off)'), 'custom' => __('Custom (one-off)'),
]) ])
->default('standard'), ->default('standard'),
Placeholder::make('paddle_discount_id') Placeholder::make('lemonsqueezy_discount_id')
->label(__('Paddle Discount ID')) ->label(__('Lemon Squeezy Discount ID'))
->content(fn ($record) => $record?->paddle_discount_id ?? '—'), ->content(fn ($record) => $record?->lemonsqueezy_discount_id ?? '—'),
Placeholder::make('paddle_last_synced_at') Placeholder::make('lemonsqueezy_last_synced_at')
->label(__('Last synced')) ->label(__('Last synced'))
->content(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'), ->content(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
Placeholder::make('redemptions_count') Placeholder::make('redemptions_count')
->label(__('Total redemptions')) ->label(__('Total redemptions'))
->content(fn ($record) => number_format($record?->redemptions_count ?? 0)), ->content(fn ($record) => number_format($record?->redemptions_count ?? 0)),

View File

@@ -63,17 +63,17 @@ class CouponInfolist
TextEntry::make('description')->label(__('Description'))->columnSpanFull(), TextEntry::make('description')->label(__('Description'))->columnSpanFull(),
KeyValueEntry::make('metadata')->label(__('Metadata'))->columnSpanFull(), KeyValueEntry::make('metadata')->label(__('Metadata'))->columnSpanFull(),
]), ]),
Section::make(__('Paddle')) Section::make(__('Lemon Squeezy'))
->columns(3) ->columns(3)
->schema([ ->schema([
TextEntry::make('paddle_discount_id') TextEntry::make('lemonsqueezy_discount_id')
->label(__('Discount ID')) ->label(__('Discount ID'))
->copyable() ->copyable()
->placeholder('—'), ->placeholder('—'),
TextEntry::make('paddle_last_synced_at') TextEntry::make('lemonsqueezy_last_synced_at')
->label(__('Last synced')) ->label(__('Last synced'))
->state(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'), ->state(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
TextEntry::make('paddle_mode') TextEntry::make('lemonsqueezy_mode')
->label(__('Mode')) ->label(__('Mode'))
->badge() ->badge()
->placeholder('standard'), ->placeholder('standard'),

View File

@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Tables;
use App\Enums\CouponStatus; use App\Enums\CouponStatus;
use App\Enums\CouponType; use App\Enums\CouponType;
use App\Jobs\SyncCouponToPaddle; use App\Jobs\SyncCouponToLemonSqueezy;
use App\Services\Audit\SuperAdminAuditLogger; use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
@@ -105,9 +105,9 @@ class CouponsTable
static::class static::class
)), )),
Action::make('sync') Action::make('sync')
->label(__('Sync to Paddle')) ->label(__('Sync to Lemon Squeezy'))
->icon('heroicon-m-arrow-path') ->icon('heroicon-m-arrow-path')
->action(fn ($record) => SyncCouponToPaddle::dispatch($record)) ->action(fn ($record) => SyncCouponToLemonSqueezy::dispatch($record))
->requiresConfirmation(), ->requiresConfirmation(),
]) ])
->toolbarActions([ ->toolbarActions([

View File

@@ -63,8 +63,8 @@ class GiftVoucherResource extends Resource
->label('Empfänger') ->label('Empfänger')
->toggleable() ->toggleable()
->searchable(), ->searchable(),
TextColumn::make('paddle_transaction_id') TextColumn::make('lemonsqueezy_order_id')
->label('Paddle Tx') ->label('Lemon Squeezy Order')
->toggleable() ->toggleable()
->copyable() ->copyable()
->wrap(), ->wrap(),

View File

@@ -46,24 +46,27 @@ class ListGiftVouchers extends ListRecords
]) ])
->action(function (array $data, GiftVoucherService $service): void { ->action(function (array $data, GiftVoucherService $service): void {
$payload = [ $payload = [
'id' => null, 'meta' => [
'metadata' => [ 'custom_data' => [
'type' => 'gift_voucher', 'type' => 'gift_voucher',
'purchaser_email' => $data['purchaser_email'], 'purchaser_email' => $data['purchaser_email'],
'recipient_email' => $data['recipient_email'] ?? null, 'recipient_email' => $data['recipient_email'] ?? null,
'recipient_name' => $data['recipient_name'] ?? null, 'recipient_name' => $data['recipient_name'] ?? null,
'message' => $data['message'] ?? null, 'message' => $data['message'] ?? null,
'gift_code' => $data['code'] ?? null, 'gift_code' => $data['code'] ?? null,
],
], ],
'currency_code' => $data['currency'] ?? 'EUR', 'data' => [
'totals' => [ 'id' => 'manual_'.Str::uuid(),
'grand_total' => [ 'attributes' => [
'amount' => (float) $data['amount'], 'currency' => $data['currency'] ?? 'EUR',
'total' => (float) $data['amount'] * 100,
'user_email' => $data['purchaser_email'],
], ],
], ],
]; ];
$voucher = $service->issueFromPaddle($payload); $voucher = $service->issueFromLemonSqueezy($payload);
app(SuperAdminAuditLogger::class)->recordModelMutation( app(SuperAdminAuditLogger::class)->recordModelMutation(
'issued', 'issued',

View File

@@ -4,7 +4,7 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster; use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\PackageAddonResource\Pages; use App\Filament\Resources\PackageAddonResource\Pages;
use App\Jobs\SyncPackageAddonToPaddle; use App\Jobs\SyncPackageAddonToLemonSqueezy;
use App\Models\PackageAddon; use App\Models\PackageAddon;
use App\Services\Audit\SuperAdminAuditLogger; use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions; use Filament\Actions;
@@ -50,9 +50,9 @@ class PackageAddonResource extends Resource
->required() ->required()
->unique(ignoreRecord: true) ->unique(ignoreRecord: true)
->maxLength(191), ->maxLength(191),
TextInput::make('price_id') TextInput::make('variant_id')
->label('Paddle Preis-ID') ->label('Lemon Squeezy Variant-ID')
->helperText('Paddle Billing Preis-ID für dieses Add-on') ->helperText('Variant-ID aus Lemon Squeezy für dieses Add-on')
->maxLength(191), ->maxLength(191),
TextInput::make('sort') TextInput::make('sort')
->label('Sortierung') ->label('Sortierung')
@@ -96,8 +96,8 @@ class PackageAddonResource extends Resource
->label('Schlüssel') ->label('Schlüssel')
->copyable() ->copyable()
->sortable(), ->sortable(),
TextColumn::make('price_id') TextColumn::make('variant_id')
->label('Paddle Preis-ID') ->label('Lemon Squeezy Variant-ID')
->toggleable() ->toggleable()
->copyable(), ->copyable(),
TextColumn::make('extra_photos')->label('Fotos +'), TextColumn::make('extra_photos')->label('Fotos +'),
@@ -120,16 +120,16 @@ class PackageAddonResource extends Resource
->label('Aktiv'), ->label('Aktiv'),
]) ])
->actions([ ->actions([
Actions\Action::make('syncPaddle') Actions\Action::make('syncLemonSqueezy')
->label('Mit Paddle synchronisieren') ->label('Mit Lemon Squeezy synchronisieren')
->icon('heroicon-o-cloud-arrow-up') ->icon('heroicon-o-cloud-arrow-up')
->action(function (PackageAddon $record) { ->action(function (PackageAddon $record) {
SyncPackageAddonToPaddle::dispatch($record->id); SyncPackageAddonToLemonSqueezy::dispatch($record->id);
Notification::make() Notification::make()
->success() ->success()
->title('Paddle-Sync gestartet') ->title('Lemon Squeezy-Sync gestartet')
->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.') ->body('Das Add-on wird im Hintergrund mit Lemon Squeezy abgeglichen.')
->send(); ->send();
}), }),
Actions\EditAction::make() Actions\EditAction::make()

View File

@@ -4,8 +4,8 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster; use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\PackageResource\Pages; use App\Filament\Resources\PackageResource\Pages;
use App\Jobs\PullPackageFromPaddle; use App\Jobs\PullPackageFromLemonSqueezy;
use App\Jobs\SyncPackageToPaddle; use App\Jobs\SyncPackageToLemonSqueezy;
use App\Models\Package; use App\Models\Package;
use App\Services\Audit\SuperAdminAuditLogger; use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum; use BackedEnum;
@@ -172,31 +172,31 @@ class PackageResource extends Resource
->columnSpanFull() ->columnSpanFull()
->default([]), ->default([]),
]), ]),
Section::make('Paddle Billing') Section::make('Lemon Squeezy Billing')
->columns(2) ->columns(2)
->schema([ ->schema([
TextInput::make('paddle_product_id') TextInput::make('lemonsqueezy_product_id')
->label('Paddle Produkt-ID') ->label('Lemon Squeezy Produkt-ID')
->maxLength(191) ->maxLength(191)
->helperText('Produkt aus Paddle Billing. Leer lassen, wenn noch nicht synchronisiert.') ->helperText('Produkt aus Lemon Squeezy. Leer lassen, wenn noch nicht synchronisiert.')
->placeholder('nicht verknüpft'), ->placeholder('nicht verknüpft'),
TextInput::make('paddle_price_id') TextInput::make('lemonsqueezy_variant_id')
->label('Paddle Preis-ID') ->label('Lemon Squeezy Variant-ID')
->maxLength(191) ->maxLength(191)
->helperText('Preis-ID aus Paddle Billing, verknüpft mit diesem Paket.') ->helperText('Variant-ID aus Lemon Squeezy, verknüpft mit diesem Paket.')
->placeholder('nicht verknüpft'), ->placeholder('nicht verknüpft'),
Placeholder::make('paddle_sync_status') Placeholder::make('lemonsqueezy_sync_status')
->label('Sync-Status') ->label('Sync-Status')
->content(fn (?Package $record) => $record?->paddle_sync_status ? Str::headline($record->paddle_sync_status) : '') ->content(fn (?Package $record) => $record?->lemonsqueezy_sync_status ? Str::headline($record->lemonsqueezy_sync_status) : '')
->columnSpanFull(), ->columnSpanFull(),
Placeholder::make('paddle_synced_at') Placeholder::make('lemonsqueezy_synced_at')
->label('Zuletzt synchronisiert') ->label('Zuletzt synchronisiert')
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '') ->content(fn (?Package $record) => $record?->lemonsqueezy_synced_at ? $record->lemonsqueezy_synced_at->diffForHumans() : '')
->columnSpanFull(), ->columnSpanFull(),
Placeholder::make('paddle_sync_error') Placeholder::make('lemonsqueezy_sync_error')
->label('Letzter Fehler') ->label('Letzter Fehler')
->content(fn (?Package $record) => $record?->paddle_sync_error_message ?? '') ->content(fn (?Package $record) => $record?->lemonsqueezy_sync_error_message ?? '')
->visible(fn (?Package $record) => filled($record?->paddle_sync_error_message)) ->visible(fn (?Package $record) => filled($record?->lemonsqueezy_sync_error_message))
->columnSpanFull(), ->columnSpanFull(),
]), ]),
]); ]);
@@ -263,15 +263,15 @@ class PackageResource extends Resource
->label('Features') ->label('Features')
->wrap() ->wrap()
->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)), ->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)),
TextColumn::make('paddle_product_id') TextColumn::make('lemonsqueezy_product_id')
->label('Paddle Produkt') ->label('Lemon Squeezy Produkt')
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'), ->formatStateUsing(fn ($state) => $state ?: '-'),
TextColumn::make('paddle_price_id') TextColumn::make('lemonsqueezy_variant_id')
->label('Paddle Preis') ->label('Lemon Squeezy Variant')
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'), ->formatStateUsing(fn ($state) => $state ?: '-'),
BadgeColumn::make('paddle_sync_status') BadgeColumn::make('lemonsqueezy_sync_status')
->label('Sync-Status') ->label('Sync-Status')
->colors([ ->colors([
'success' => 'synced', 'success' => 'synced',
@@ -281,13 +281,13 @@ class PackageResource extends Resource
]) ])
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null) ->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_synced_at') TextColumn::make('lemonsqueezy_synced_at')
->label('Sync am') ->label('Sync am')
->dateTime() ->dateTime()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_sync_error_message') TextColumn::make('lemonsqueezy_sync_error_message')
->label('Sync-Fehler') ->label('Sync-Fehler')
->getStateUsing(fn (Package $record) => $record->paddle_sync_error_message) ->getStateUsing(fn (Package $record) => $record->lemonsqueezy_sync_error_message)
->wrap() ->wrap()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
]) ])
@@ -301,43 +301,43 @@ class PackageResource extends Resource
TrashedFilter::make(), TrashedFilter::make(),
]) ])
->actions([ ->actions([
Actions\Action::make('syncPaddle') Actions\Action::make('syncLemonSqueezy')
->label('Mit Paddle abgleichen') ->label('Mit Lemon Squeezy abgleichen')
->icon('heroicon-o-cloud-arrow-up') ->icon('heroicon-o-cloud-arrow-up')
->color('success') ->color('success')
->requiresConfirmation() ->requiresConfirmation()
->disabled(fn (Package $record) => $record->paddle_sync_status === 'syncing') ->disabled(fn (Package $record) => $record->lemonsqueezy_sync_status === 'syncing')
->action(function (Package $record) { ->action(function (Package $record) {
SyncPackageToPaddle::dispatch($record->id); SyncPackageToLemonSqueezy::dispatch($record->id);
Notification::make() Notification::make()
->success() ->success()
->title('Paddle-Sync gestartet') ->title('Lemon Squeezy-Sync gestartet')
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.') ->body('Das Paket wird im Hintergrund mit Lemon Squeezy abgeglichen.')
->send(); ->send();
}), }),
Actions\Action::make('linkPaddle') Actions\Action::make('linkLemonSqueezy')
->label('Paddle verknüpfen') ->label('Lemon Squeezy verknüpfen')
->icon('heroicon-o-link') ->icon('heroicon-o-link')
->color('info') ->color('info')
->form([ ->form([
TextInput::make('paddle_product_id') TextInput::make('lemonsqueezy_product_id')
->label('Paddle Produkt-ID') ->label('Lemon Squeezy Produkt-ID')
->required() ->required()
->maxLength(191), ->maxLength(191),
TextInput::make('paddle_price_id') TextInput::make('lemonsqueezy_variant_id')
->label('Paddle Preis-ID') ->label('Lemon Squeezy Variant-ID')
->required() ->required()
->maxLength(191), ->maxLength(191),
]) ])
->fillForm(fn (Package $record) => [ ->fillForm(fn (Package $record) => [
'paddle_product_id' => $record->paddle_product_id, 'lemonsqueezy_product_id' => $record->lemonsqueezy_product_id,
'paddle_price_id' => $record->paddle_price_id, 'lemonsqueezy_variant_id' => $record->lemonsqueezy_variant_id,
]) ])
->action(function (Package $record, array $data): void { ->action(function (Package $record, array $data): void {
$record->linkPaddleIds($data['paddle_product_id'], $data['paddle_price_id']); $record->linkLemonSqueezyIds($data['lemonsqueezy_product_id'], $data['lemonsqueezy_variant_id']);
PullPackageFromPaddle::dispatch($record->id); PullPackageFromLemonSqueezy::dispatch($record->id);
app(SuperAdminAuditLogger::class)->recordModelMutation( app(SuperAdminAuditLogger::class)->recordModelMutation(
'linked', 'linked',
@@ -348,22 +348,22 @@ class PackageResource extends Resource
Notification::make() Notification::make()
->success() ->success()
->title('Paddle-Verknüpfung gespeichert') ->title('Lemon Squeezy-Verknüpfung gespeichert')
->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.') ->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.')
->send(); ->send();
}), }),
Actions\Action::make('pullPaddle') Actions\Action::make('pullLemonSqueezy')
->label('Status von Paddle holen') ->label('Status von Lemon Squeezy holen')
->icon('heroicon-o-cloud-arrow-down') ->icon('heroicon-o-cloud-arrow-down')
->disabled(fn (Package $record) => ! $record->paddle_product_id && ! $record->paddle_price_id) ->disabled(fn (Package $record) => ! $record->lemonsqueezy_product_id && ! $record->lemonsqueezy_variant_id)
->requiresConfirmation() ->requiresConfirmation()
->action(function (Package $record) { ->action(function (Package $record) {
PullPackageFromPaddle::dispatch($record->id); PullPackageFromLemonSqueezy::dispatch($record->id);
Notification::make() Notification::make()
->info() ->info()
->title('Paddle-Abgleich angefordert') ->title('Lemon Squeezy-Abgleich angefordert')
->body('Der aktuelle Stand aus Paddle wird geladen und hier hinterlegt.') ->body('Der aktuelle Stand aus Lemon Squeezy wird geladen und hier hinterlegt.')
->send(); ->send();
}), }),
ViewAction::make(), ViewAction::make(),

View File

@@ -8,7 +8,7 @@ use App\Models\PackagePurchase;
use App\Notifications\Customer\RefundReceipt; use App\Notifications\Customer\RefundReceipt;
use App\Notifications\Ops\RefundProcessed; use App\Notifications\Ops\RefundProcessed;
use App\Services\Audit\SuperAdminAuditLogger; use App\Services\Audit\SuperAdminAuditLogger;
use App\Services\Paddle\PaddleTransactionService; use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
@@ -203,15 +203,15 @@ class PurchaseResource extends Resource
$refundSuccess = true; $refundSuccess = true;
$errorMessage = null; $errorMessage = null;
if ($record->provider === 'paddle' && $record->provider_id) { if ($record->provider === 'lemonsqueezy' && $record->provider_id) {
try { try {
/** @var PaddleTransactionService $paddle */ /** @var LemonSqueezyOrderService $lemonsqueezy */
$paddle = App::make(PaddleTransactionService::class); $lemonsqueezy = App::make(LemonSqueezyOrderService::class);
$paddle->refund($record->provider_id, ['reason' => $reason]); $lemonsqueezy->refund($record->provider_id, ['reason' => $reason]);
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
$refundSuccess = false; $refundSuccess = false;
$errorMessage = $exception->getMessage(); $errorMessage = $exception->getMessage();
Log::warning('Paddle refund failed', [ Log::warning('Lemon Squeezy refund failed', [
'purchase_id' => $record->id, 'purchase_id' => $record->id,
'provider_id' => $record->provider_id, 'provider_id' => $record->provider_id,
'error' => $exception->getMessage(), 'error' => $exception->getMessage(),

View File

@@ -35,7 +35,7 @@ class ViewPurchase extends ViewRecord
->visible(fn ($record): bool => ! $record->refunded) ->visible(fn ($record): bool => ! $record->refunded)
->action(function ($record) { ->action(function ($record) {
$record->update(['refunded' => true]); $record->update(['refunded' => true]);
// TODO: Call Paddle API for actual refund // TODO: Call Lemon Squeezy API for actual refund
app(SuperAdminAuditLogger::class)->record( app(SuperAdminAuditLogger::class)->record(
'purchase.refunded', 'purchase.refunded',

View File

@@ -73,10 +73,10 @@ class TenantResource extends Resource
->email() ->email()
->required() ->required()
->maxLength(255), ->maxLength(255),
TextInput::make('paddle_customer_id') TextInput::make('lemonsqueezy_customer_id')
->label('Paddle Customer ID') ->label('Lemon Squeezy Customer ID')
->maxLength(191) ->maxLength(191)
->helperText('Verknuepfung mit Paddle Billing Kundenkonto.') ->helperText('Verknüpfung mit Lemon Squeezy Kundenkonto.')
->nullable(), ->nullable(),
TextInput::make('total_revenue') TextInput::make('total_revenue')
->label(__('admin.tenants.fields.total_revenue')) ->label(__('admin.tenants.fields.total_revenue'))
@@ -135,8 +135,8 @@ class TenantResource extends Resource
->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'), ->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'),
Tables\Columns\TextColumn::make('slug')->searchable(), Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('contact_email'), Tables\Columns\TextColumn::make('contact_email'),
Tables\Columns\TextColumn::make('paddle_customer_id') Tables\Columns\TextColumn::make('lemonsqueezy_customer_id')
->label('Paddle Customer') ->label('Lemon Squeezy Customer')
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'), ->formatStateUsing(fn ($state) => $state ?: '-'),
Tables\Columns\TextColumn::make('active_reseller_package_id') Tables\Columns\TextColumn::make('active_reseller_package_id')

View File

@@ -44,7 +44,7 @@ class PackagePurchasesRelationManager extends RelationManager
Select::make('provider') Select::make('provider')
->label('Anbieter') ->label('Anbieter')
->options([ ->options([
'paddle' => 'Paddle', 'lemonsqueezy' => 'Lemon Squeezy',
'manual' => 'Manuell', 'manual' => 'Manuell',
'free' => 'Kostenlos', 'free' => 'Kostenlos',
]) ])
@@ -89,7 +89,7 @@ class PackagePurchasesRelationManager extends RelationManager
TextColumn::make('provider') TextColumn::make('provider')
->badge() ->badge()
->color(fn (string $state): string => match ($state) { ->color(fn (string $state): string => match ($state) {
'paddle' => 'success', 'lemonsqueezy' => 'success',
'manual' => 'gray', 'manual' => 'gray',
'free' => 'success', 'free' => 'success',
default => 'gray', default => 'gray',
@@ -116,7 +116,7 @@ class PackagePurchasesRelationManager extends RelationManager
]), ]),
SelectFilter::make('provider') SelectFilter::make('provider')
->options([ ->options([
'paddle' => 'Paddle', 'lemonsqueezy' => 'Lemon Squeezy',
'manual' => 'Manuell', 'manual' => 'Manuell',
'free' => 'Kostenlos', 'free' => 'Kostenlos',
]), ]),

View File

@@ -40,10 +40,10 @@ class TenantPackagesRelationManager extends RelationManager
DateTimePicker::make('expires_at') DateTimePicker::make('expires_at')
->label('Ablaufdatum') ->label('Ablaufdatum')
->required(), ->required(),
TextInput::make('paddle_subscription_id') TextInput::make('lemonsqueezy_subscription_id')
->label('Paddle Subscription ID') ->label('Lemon Squeezy Subscription ID')
->maxLength(191) ->maxLength(191)
->helperText('Abonnement-ID aus Paddle Billing.') ->helperText('Abonnement-ID aus Lemon Squeezy.')
->nullable(), ->nullable(),
Toggle::make('active') Toggle::make('active')
->label('Aktiv'), ->label('Aktiv'),
@@ -75,8 +75,8 @@ class TenantPackagesRelationManager extends RelationManager
TextColumn::make('expires_at') TextColumn::make('expires_at')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
TextColumn::make('paddle_subscription_id') TextColumn::make('lemonsqueezy_subscription_id')
->label('Paddle Subscription') ->label('Lemon Squeezy Subscription')
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'), ->formatStateUsing(fn ($state) => $state ?: '-'),
IconColumn::make('active') IconColumn::make('active')

View File

@@ -22,8 +22,8 @@ class TenantInfolist
TextEntry::make('user.full_name') TextEntry::make('user.full_name')
->label(__('admin.tenants.fields.owner')) ->label(__('admin.tenants.fields.owner'))
->state(fn (Tenant $record) => $record->user?->full_name ?? '—'), ->state(fn (Tenant $record) => $record->user?->full_name ?? '—'),
TextEntry::make('paddle_customer_id') TextEntry::make('lemonsqueezy_customer_id')
->label('Paddle Customer ID') ->label('Lemon Squeezy Customer ID')
->placeholder('—'), ->placeholder('—'),
TextEntry::make('total_revenue') TextEntry::make('total_revenue')
->label(__('admin.tenants.fields.total_revenue')) ->label(__('admin.tenants.fields.total_revenue'))

View File

@@ -24,7 +24,7 @@ class CouponPreviewController extends Controller
$package = Package::findOrFail($data['package_id']); $package = Package::findOrFail($data['package_id']);
if (! $package->paddle_price_id) { if (! $package->lemonsqueezy_variant_id) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.package_not_configured'), 'code' => __('marketing.coupon.errors.package_not_configured'),
]); ]);

View File

@@ -36,7 +36,7 @@ class GiftVoucherCheckoutController extends Controller
if (! $checkout['checkout_url']) { if (! $checkout['checkout_url']) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'tier_key' => __('Unable to create Paddle checkout.'), 'tier_key' => __('Unable to create Lemon Squeezy checkout.'),
]); ]);
} }
@@ -46,19 +46,19 @@ class GiftVoucherCheckoutController extends Controller
public function show(Request $request): JsonResponse public function show(Request $request): JsonResponse
{ {
$data = $request->validate([ $data = $request->validate([
'checkout_id' => ['nullable', 'string', 'required_without_all:transaction_id,code'], 'checkout_id' => ['nullable', 'string', 'required_without_all:order_id,code'],
'transaction_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'], 'order_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
'code' => ['nullable', 'string', 'required_without_all:checkout_id,transaction_id'], 'code' => ['nullable', 'string', 'required_without_all:checkout_id,order_id'],
]); ]);
$voucherQuery = GiftVoucher::query(); $voucherQuery = GiftVoucher::query();
if (! empty($data['checkout_id'])) { if (! empty($data['checkout_id'])) {
$voucherQuery->where('paddle_checkout_id', $data['checkout_id']); $voucherQuery->where('lemonsqueezy_checkout_id', $data['checkout_id']);
} }
if (! empty($data['transaction_id'])) { if (! empty($data['order_id'])) {
$voucherQuery->orWhere('paddle_transaction_id', $data['transaction_id']); $voucherQuery->orWhere('lemonsqueezy_order_id', $data['order_id']);
} }
if (! empty($data['code'])) { if (! empty($data['code'])) {

View File

@@ -9,7 +9,7 @@ use App\Models\Package;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use App\Services\Checkout\CheckoutSessionService; use App\Services\Checkout\CheckoutSessionService;
use App\Services\Paddle\PaddleCheckoutService; use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -18,7 +18,7 @@ use Illuminate\Validation\ValidationException;
class PackageController extends Controller class PackageController extends Controller
{ {
public function __construct( public function __construct(
private readonly PaddleCheckoutService $paddleCheckout, private readonly LemonSqueezyCheckoutService $lemonsqueezyCheckout,
private readonly CheckoutSessionService $sessions, private readonly CheckoutSessionService $sessions,
) {} ) {}
@@ -53,7 +53,7 @@ class PackageController extends Controller
$request->validate([ $request->validate([
'package_id' => 'required|exists:packages,id', 'package_id' => 'required|exists:packages,id',
'type' => 'required|in:endcustomer,reseller', 'type' => 'required|in:endcustomer,reseller',
'payment_method' => 'required|in:paddle', 'payment_method' => 'required|in:lemonsqueezy',
'event_id' => 'nullable|exists:events,id', // For endcustomer 'event_id' => 'nullable|exists:events,id', // For endcustomer
'success_url' => 'nullable|url', 'success_url' => 'nullable|url',
'return_url' => 'nullable|url', 'return_url' => 'nullable|url',
@@ -79,7 +79,7 @@ class PackageController extends Controller
{ {
$request->validate([ $request->validate([
'package_id' => 'required|exists:packages,id', 'package_id' => 'required|exists:packages,id',
'paddle_transaction_id' => 'required|string', 'lemonsqueezy_order_id' => 'required|string',
]); ]);
$package = Package::findOrFail($request->package_id); $package = Package::findOrFail($request->package_id);
@@ -89,14 +89,14 @@ class PackageController extends Controller
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']); throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
} }
$provider = 'paddle'; $provider = 'lemonsqueezy';
DB::transaction(function () use ($request, $package, $tenant, $provider) { DB::transaction(function () use ($request, $package, $tenant, $provider) {
PackagePurchase::create([ PackagePurchase::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'package_id' => $package->id, 'package_id' => $package->id,
'provider' => $provider, 'provider' => $provider,
'provider_id' => $request->input('paddle_transaction_id'), 'provider_id' => $request->input('lemonsqueezy_order_id'),
'price' => $package->price, 'price' => $package->price,
'type' => 'endcustomer_event', 'type' => 'endcustomer_event',
'purchased_at' => now(), 'purchased_at' => now(),
@@ -161,7 +161,7 @@ class PackageController extends Controller
], 201); ], 201);
} }
public function createPaddleCheckout(Request $request): JsonResponse public function createLemonSqueezyCheckout(Request $request): JsonResponse
{ {
$request->validate([ $request->validate([
'package_id' => 'required|exists:packages,id', 'package_id' => 'required|exists:packages,id',
@@ -181,15 +181,15 @@ class PackageController extends Controller
throw ValidationException::withMessages(['user' => 'User context missing.']); throw ValidationException::withMessages(['user' => 'User context missing.']);
} }
if (! $package->paddle_price_id) { if (! $package->lemonsqueezy_variant_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
} }
$session = $this->sessions->createOrResume($user, $package, [ $session = $this->sessions->createOrResume($user, $package, [
'tenant' => $tenant, 'tenant' => $tenant,
]); ]);
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY);
$now = now(); $now = now();
@@ -211,14 +211,14 @@ class PackageController extends Controller
], ],
]; ];
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload); $checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, $payload);
$session->forceFill([ $session->forceFill([
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id, 'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paddle_checkout_id' => $checkout['id'] ?? null, 'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null, 'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null, 'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
])), ])),
])->save(); ])->save();
@@ -239,7 +239,7 @@ class PackageController extends Controller
} }
} }
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url'); $checkoutUrl = data_get($session->provider_metadata ?? [], 'lemonsqueezy_checkout_url');
return response()->json([ return response()->json([
'status' => $session->status, 'status' => $session->status,
@@ -297,11 +297,11 @@ class PackageController extends Controller
private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse
{ {
if (! $package->paddle_price_id) { if (! $package->lemonsqueezy_variant_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
} }
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [ $checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, [
'success_url' => $request->input('success_url'), 'success_url' => $request->input('success_url'),
'return_url' => $request->input('return_url'), 'return_url' => $request->input('return_url'),
'metadata' => array_filter([ 'metadata' => array_filter([

View File

@@ -13,7 +13,7 @@ class EventAddonCatalogController extends Controller
public function index(): JsonResponse public function index(): JsonResponse
{ {
$addons = collect($this->catalog->all()) $addons = collect($this->catalog->all())
->filter(fn (array $addon) => ! empty($addon['price_id'])) ->filter(fn (array $addon) => ! empty($addon['variant_id']))
->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key])) ->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key]))
->values() ->values()
->all(); ->all();

View File

@@ -4,10 +4,9 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\EventPackageAddon; use App\Models\EventPackageAddon;
use App\Services\Paddle\Exceptions\PaddleException; use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\Paddle\PaddleCustomerPortalService; use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use App\Services\Paddle\PaddleCustomerService; use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
use App\Services\Paddle\PaddleTransactionService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@@ -16,9 +15,8 @@ use Illuminate\Support\Facades\Log;
class TenantBillingController extends Controller class TenantBillingController extends Controller
{ {
public function __construct( public function __construct(
private readonly PaddleTransactionService $paddleTransactions, private readonly LemonSqueezyOrderService $orders,
private readonly PaddleCustomerService $paddleCustomers, private readonly LemonSqueezySubscriptionService $subscriptions,
private readonly PaddleCustomerPortalService $portalSessions,
) {} ) {}
public function transactions(Request $request): JsonResponse public function transactions(Request $request): JsonResponse
@@ -32,20 +30,15 @@ class TenantBillingController extends Controller
], 404); ], 404);
} }
if (! $tenant->paddle_customer_id) { if (! $tenant->lemonsqueezy_customer_id) {
try { return response()->json([
$this->paddleCustomers->ensureCustomerId($tenant); 'data' => [],
} catch (\Throwable $exception) { 'meta' => [
Log::warning('Failed to resolve Paddle customer for tenant', [ 'next' => null,
'tenant_id' => $tenant->id, 'previous' => null,
'error' => $exception->getMessage(), 'has_more' => false,
]); ],
]);
return response()->json([
'data' => [],
'message' => 'Failed to resolve Paddle customer.',
], 502);
}
} }
$cursor = $request->query('cursor'); $cursor = $request->query('cursor');
@@ -60,16 +53,16 @@ class TenantBillingController extends Controller
} }
try { try {
$result = $this->paddleTransactions->listForCustomer($tenant->paddle_customer_id, $query); $result = $this->orders->listForCustomer($tenant->lemonsqueezy_customer_id, $query);
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
Log::warning('Failed to load Paddle transactions', [ Log::warning('Failed to load Lemon Squeezy transactions', [
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'error' => $exception->getMessage(), 'error' => $exception->getMessage(),
]); ]);
return response()->json([ return response()->json([
'data' => [], 'data' => [],
'message' => 'Failed to load Paddle transactions.', 'message' => 'Failed to load Lemon Squeezy transactions.',
], 502); ], 502);
} }
@@ -143,68 +136,64 @@ class TenantBillingController extends Controller
], 404); ], 404);
} }
$customerId = null; $subscriptionId = null;
try { try {
$customerId = $this->paddleCustomers->ensureCustomerId($tenant); $subscriptionId = $tenant->getActiveResellerPackage()?->lemonsqueezy_subscription_id;
if (! $subscriptionId) {
return response()->json([
'message' => 'No active subscription found.',
], 404);
}
Log::debug('Creating Paddle customer portal session', [ Log::debug('Fetching Lemon Squeezy subscription portal URL', [
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'paddle_customer_id' => $customerId, 'lemonsqueezy_subscription_id' => $subscriptionId,
'paddle_environment' => config('paddle.environment'),
'paddle_base_url' => config('paddle.base_url'),
]); ]);
$session = $this->portalSessions->createSession($customerId); $subscription = $this->subscriptions->retrieve($subscriptionId);
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
$context = [ $context = [
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id, 'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
'error' => $exception->getMessage(), 'error' => $exception->getMessage(),
'paddle_environment' => config('paddle.environment'),
'paddle_base_url' => config('paddle.base_url'),
]; ];
if ($exception instanceof PaddleException) { if ($exception instanceof LemonSqueezyException) {
$context['paddle_status'] = $exception->status(); $context['lemonsqueezy_status'] = $exception->status();
$context['paddle_error_code'] = Arr::get($exception->context(), 'error.code'); $context['lemonsqueezy_error'] = Arr::get($exception->context(), 'errors.0');
$context['paddle_error_message'] = Arr::get($exception->context(), 'error.message'); $context['lemonsqueezy_errors'] = Arr::get($exception->context(), 'errors');
$context['paddle_error_detail'] = Arr::get($exception->context(), 'error.detail'); $context['lemonsqueezy_request_id'] = Arr::get($exception->context(), 'meta.request_id');
$context['paddle_error_doc_url'] = Arr::get($exception->context(), 'error.documentation_url');
$context['paddle_request_id'] = Arr::get($exception->context(), 'meta.request_id');
$context['paddle_errors'] = Arr::get($exception->context(), 'error.errors');
} }
Log::warning('Failed to create Paddle customer portal session', [ Log::warning('Failed to fetch Lemon Squeezy subscription portal URL', [
...$context, ...$context,
]); ]);
return response()->json([ return response()->json([
'message' => 'Failed to create Paddle customer portal session.', 'message' => 'Failed to fetch Lemon Squeezy subscription portal URL.',
], 502); ], 502);
} }
$url = Arr::get($session, 'data.urls.general.overview') $url = $this->subscriptions->portalUrl($subscription)
?? Arr::get($session, 'data.urls.general') ?? $this->subscriptions->updatePaymentMethodUrl($subscription);
?? Arr::get($session, 'urls.general.overview')
?? Arr::get($session, 'urls.general');
if (! $url) { if (! $url) {
$sessionData = Arr::get($session, 'data'); $sessionData = Arr::get($subscription, 'data');
$sessionUrls = Arr::get($session, 'data.urls') ?? Arr::get($session, 'urls'); $sessionUrls = Arr::get($subscription, 'attributes.urls');
Log::warning('Paddle customer portal session missing URL', [ Log::warning('Lemon Squeezy subscription missing portal URL', [
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id, 'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
'paddle_environment' => config('paddle.environment'), 'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
'paddle_base_url' => config('paddle.base_url'), 'subscription_keys' => array_keys($subscription),
'session_keys' => array_keys($session),
'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null, 'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null,
'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null, 'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null,
]); ]);
return response()->json([ return response()->json([
'message' => 'Paddle customer portal session missing URL.', 'message' => 'Lemon Squeezy subscription missing portal URL.',
], 502); ], 502);
} }

View File

@@ -15,8 +15,8 @@ use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Checkout\CheckoutAssignmentService; use App\Services\Checkout\CheckoutAssignmentService;
use App\Services\Checkout\CheckoutSessionService; use App\Services\Checkout\CheckoutSessionService;
use App\Services\Paddle\Exceptions\PaddleException; use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\Paddle\PaddleTransactionService; use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use App\Support\CheckoutRequestContext; use App\Support\CheckoutRequestContext;
use App\Support\CheckoutRoutes; use App\Support\CheckoutRoutes;
use App\Support\Concerns\PresentsPackages; use App\Support\Concerns\PresentsPackages;
@@ -74,9 +74,9 @@ class CheckoutController extends Controller
'error' => $facebookError, 'error' => $facebookError,
'profile' => $facebookProfile, 'profile' => $facebookProfile,
], ],
'paddle' => [ 'lemonsqueezy' => [
'environment' => config('paddle.environment'), 'store_id' => config('lemonsqueezy.store_id'),
'client_token' => config('paddle.client_token'), 'test_mode' => config('lemonsqueezy.test_mode', false),
], ],
]); ]);
} }
@@ -271,9 +271,9 @@ class CheckoutController extends Controller
CheckoutSession $session, CheckoutSession $session,
CheckoutSessionService $sessions, CheckoutSessionService $sessions,
CheckoutAssignmentService $assignment, CheckoutAssignmentService $assignment,
PaddleTransactionService $transactions, LemonSqueezyOrderService $orders,
): JsonResponse { ): JsonResponse {
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions); $this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
$session->refresh(); $session->refresh();
@@ -288,56 +288,56 @@ class CheckoutController extends Controller
CheckoutSession $session, CheckoutSession $session,
CheckoutSessionService $sessions, CheckoutSessionService $sessions,
CheckoutAssignmentService $assignment, CheckoutAssignmentService $assignment,
PaddleTransactionService $transactions, LemonSqueezyOrderService $orders,
): JsonResponse { ): JsonResponse {
$validated = $request->validated(); $validated = $request->validated();
$transactionId = $validated['transaction_id'] ?? null; $orderId = $validated['order_id'] ?? null;
$checkoutId = $validated['checkout_id'] ?? null; $checkoutId = $validated['checkout_id'] ?? null;
$metadata = $session->provider_metadata ?? []; $metadata = $session->provider_metadata ?? [];
$metadataUpdated = false; $metadataUpdated = false;
if ($transactionId) { if ($orderId) {
$session->paddle_transaction_id = $transactionId; $session->lemonsqueezy_order_id = $orderId;
$metadata['paddle_transaction_id'] = $transactionId; $metadata['lemonsqueezy_order_id'] = $orderId;
$metadataUpdated = true; $metadataUpdated = true;
} }
if ($checkoutId) { if ($checkoutId) {
$metadata['paddle_checkout_id'] = $checkoutId; $metadata['lemonsqueezy_checkout_id'] = $checkoutId;
$metadataUpdated = true; $metadataUpdated = true;
} }
if ($metadataUpdated) { if ($metadataUpdated) {
$metadata['paddle_client_event_at'] = now()->toIso8601String(); $metadata['lemonsqueezy_client_event_at'] = now()->toIso8601String();
$session->provider_metadata = $metadata; $session->provider_metadata = $metadata;
$session->save(); $session->save();
} }
if (app()->environment('local') if (app()->environment('local')
&& $session->provider === CheckoutSession::PROVIDER_PADDLE && $session->provider === CheckoutSession::PROVIDER_LEMONSQUEEZY
&& ! in_array($session->status, [ && ! in_array($session->status, [
CheckoutSession::STATUS_COMPLETED, CheckoutSession::STATUS_COMPLETED,
CheckoutSession::STATUS_FAILED, CheckoutSession::STATUS_FAILED,
CheckoutSession::STATUS_CANCELLED, CheckoutSession::STATUS_CANCELLED,
], true) ], true)
&& ($transactionId || $checkoutId) && ($orderId || $checkoutId)
) { ) {
$sessions->markProcessing($session, array_filter([ $sessions->markProcessing($session, array_filter([
'paddle_status' => 'completed', 'lemonsqueezy_status' => 'paid',
'paddle_transaction_id' => $transactionId, 'lemonsqueezy_order_id' => $orderId,
'paddle_local_confirmed_at' => now()->toIso8601String(), 'lemonsqueezy_local_confirmed_at' => now()->toIso8601String(),
])); ]));
$assignment->finalise($session, [ $assignment->finalise($session, [
'source' => 'paddle_local', 'source' => 'lemonsqueezy_local',
'provider' => CheckoutSession::PROVIDER_PADDLE, 'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
'provider_reference' => $transactionId ?? $checkoutId, 'provider_reference' => $orderId ?? $checkoutId,
]); ]);
$sessions->markCompleted($session); $sessions->markCompleted($session);
} else { } else {
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions); $this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
} }
$session->refresh(); $session->refresh();
@@ -419,13 +419,13 @@ class CheckoutController extends Controller
return $price <= 0; return $price <= 0;
} }
private function attemptPaddleRecovery( private function attemptLemonSqueezyRecovery(
CheckoutSession $session, CheckoutSession $session,
CheckoutSessionService $sessions, CheckoutSessionService $sessions,
CheckoutAssignmentService $assignment, CheckoutAssignmentService $assignment,
PaddleTransactionService $transactions LemonSqueezyOrderService $orders
): void { ): void {
if ($session->provider !== CheckoutSession::PROVIDER_PADDLE) { if ($session->provider !== CheckoutSession::PROVIDER_LEMONSQUEEZY) {
return; return;
} }
@@ -438,7 +438,7 @@ class CheckoutController extends Controller
} }
$metadata = $session->provider_metadata ?? []; $metadata = $session->provider_metadata ?? [];
$lastPollAt = $metadata['paddle_poll_at'] ?? null; $lastPollAt = $metadata['lemonsqueezy_poll_at'] ?? null;
$now = now(); $now = now();
if ($lastPollAt) { if ($lastPollAt) {
@@ -452,39 +452,31 @@ class CheckoutController extends Controller
} }
} }
$checkoutId = $metadata['paddle_checkout_id'] ?? $session->paddle_checkout_id ?? null; $checkoutId = $metadata['lemonsqueezy_checkout_id'] ?? $session->lemonsqueezy_checkout_id ?? null;
$transactionId = $metadata['paddle_transaction_id'] ?? $session->paddle_transaction_id ?? null; $orderId = $metadata['lemonsqueezy_order_id'] ?? $session->lemonsqueezy_order_id ?? null;
if (! $checkoutId && ! $transactionId) { if (! $checkoutId && ! $orderId) {
Log::info('[Checkout] Paddle recovery missing checkout reference, falling back to custom data scan', [ Log::info('[Checkout] Lemon Squeezy recovery missing checkout reference', [
'session_id' => $session->id, 'session_id' => $session->id,
]); ]);
} }
$metadata['paddle_poll_at'] = $now->toIso8601String(); $metadata['lemonsqueezy_poll_at'] = $now->toIso8601String();
$session->forceFill([ $session->forceFill([
'provider_metadata' => $metadata, 'provider_metadata' => $metadata,
])->save(); ])->save();
try { try {
$transaction = $transactionId ? $transactions->retrieve($transactionId) : null; $order = $orderId ? $orders->retrieve($orderId) : null;
if (! $transaction && $checkoutId) { if (! $order && $checkoutId) {
$transaction = $transactions->findByCheckoutId($checkoutId); $order = $orders->findByCheckoutId($checkoutId);
} }
} catch (LemonSqueezyException $exception) {
if (! $transaction) { Log::warning('[Checkout] Lemon Squeezy recovery failed', [
$transaction = $transactions->findByCustomData([
'checkout_session_id' => $session->id,
'package_id' => (string) $session->package_id,
'tenant_id' => (string) $session->tenant_id,
]);
}
} catch (PaddleException $exception) {
Log::warning('[Checkout] Paddle recovery failed', [
'session_id' => $session->id, 'session_id' => $session->id,
'checkout_id' => $checkoutId, 'checkout_id' => $checkoutId,
'transaction_id' => $transactionId, 'order_id' => $orderId,
'status' => $exception->status(), 'status' => $exception->status(),
'message' => $exception->getMessage(), 'message' => $exception->getMessage(),
'context' => $exception->context(), 'context' => $exception->context(),
@@ -492,77 +484,77 @@ class CheckoutController extends Controller
return; return;
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
Log::warning('[Checkout] Paddle recovery failed', [ Log::warning('[Checkout] Lemon Squeezy recovery failed', [
'session_id' => $session->id, 'session_id' => $session->id,
'checkout_id' => $checkoutId, 'checkout_id' => $checkoutId,
'transaction_id' => $transactionId, 'order_id' => $orderId,
'message' => $exception->getMessage(), 'message' => $exception->getMessage(),
]); ]);
return; return;
} }
if (! $transaction) { if (! $order) {
Log::info('[Checkout] Paddle recovery: transaction not found', [ Log::info('[Checkout] Lemon Squeezy recovery: order not found', [
'session_id' => $session->id, 'session_id' => $session->id,
'checkout_id' => $checkoutId, 'checkout_id' => $checkoutId,
'transaction_id' => $transactionId, 'order_id' => $orderId,
]); ]);
return; return;
} }
$status = strtolower((string) ($transaction['status'] ?? '')); $status = strtolower((string) data_get($order, 'attributes.status', ''));
$transactionId = $transactionId ?: ($transaction['id'] ?? null); $resolvedOrderId = $orderId ?: data_get($order, 'id');
if ($transactionId && $session->paddle_transaction_id !== $transactionId) { if ($resolvedOrderId && $session->lemonsqueezy_order_id !== $resolvedOrderId) {
$session->forceFill([ $session->forceFill([
'paddle_transaction_id' => $transactionId, 'lemonsqueezy_order_id' => $resolvedOrderId,
])->save(); ])->save();
} }
if ($status === 'completed') { if (in_array($status, ['paid', 'completed'], true)) {
$sessions->markProcessing($session, [ $sessions->markProcessing($session, [
'paddle_status' => $status, 'lemonsqueezy_status' => $status,
'paddle_transaction_id' => $transactionId, 'lemonsqueezy_order_id' => $resolvedOrderId,
'paddle_recovered_at' => $now->toIso8601String(), 'lemonsqueezy_recovered_at' => $now->toIso8601String(),
]); ]);
$assignment->finalise($session, [ $assignment->finalise($session, [
'source' => 'paddle_poll', 'source' => 'lemonsqueezy_poll',
'provider' => CheckoutSession::PROVIDER_PADDLE, 'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
'provider_reference' => $transactionId, 'provider_reference' => $resolvedOrderId,
'payload' => $transaction, 'payload' => $order,
]); ]);
$sessions->markCompleted($session, $now); $sessions->markCompleted($session, $now);
Log::info('[Checkout] Paddle session recovered via API', [ Log::info('[Checkout] Lemon Squeezy session recovered via API', [
'session_id' => $session->id, 'session_id' => $session->id,
'checkout_id' => $checkoutId, 'checkout_id' => $checkoutId,
'transaction_id' => $transactionId, 'order_id' => $resolvedOrderId,
]); ]);
return; return;
} }
if (in_array($status, ['failed', 'cancelled', 'canceled'], true)) { if (in_array($status, ['failed', 'cancelled', 'canceled', 'refunded', 'voided'], true)) {
$sessions->markFailed($session, 'paddle_'.$status); $sessions->markFailed($session, 'lemonsqueezy_'.$status);
Log::info('[Checkout] Paddle transaction failed', [ Log::info('[Checkout] Lemon Squeezy order failed', [
'session_id' => $session->id, 'session_id' => $session->id,
'checkout_id' => $checkoutId, 'checkout_id' => $checkoutId,
'transaction_id' => $transactionId, 'order_id' => $resolvedOrderId,
'status' => $status, 'status' => $status,
]); ]);
return; return;
} }
Log::info('[Checkout] Paddle transaction pending', [ Log::info('[Checkout] Lemon Squeezy order pending', [
'session_id' => $session->id, 'session_id' => $session->id,
'checkout_id' => $checkoutId, 'checkout_id' => $checkoutId,
'transaction_id' => $transactionId, 'order_id' => $resolvedOrderId,
'status' => $status, 'status' => $status,
]); ]);
} }

View File

@@ -2,27 +2,27 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\Paddle\PaddleCheckoutRequest; use App\Http\Requests\LemonSqueezy\LemonSqueezyCheckoutRequest;
use App\Models\CheckoutSession; use App\Models\CheckoutSession;
use App\Models\Package; use App\Models\Package;
use App\Services\Checkout\CheckoutSessionService; use App\Services\Checkout\CheckoutSessionService;
use App\Services\Coupons\CouponService; use App\Services\Coupons\CouponService;
use App\Services\Paddle\PaddleCheckoutService; use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use App\Support\CheckoutRequestContext; use App\Support\CheckoutRequestContext;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class PaddleCheckoutController extends Controller class LemonSqueezyCheckoutController extends Controller
{ {
public function __construct( public function __construct(
private readonly PaddleCheckoutService $checkout, private readonly LemonSqueezyCheckoutService $checkout,
private readonly CheckoutSessionService $sessions, private readonly CheckoutSessionService $sessions,
private readonly CouponService $coupons, private readonly CouponService $coupons,
) {} ) {}
public function create(PaddleCheckoutRequest $request): JsonResponse public function create(LemonSqueezyCheckoutRequest $request): JsonResponse
{ {
$data = $request->validated(); $data = $request->validated();
@@ -35,8 +35,8 @@ class PaddleCheckoutController extends Controller
$package = Package::findOrFail((int) $data['package_id']); $package = Package::findOrFail((int) $data['package_id']);
if (! $package->paddle_price_id) { if (! $package->lemonsqueezy_variant_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
} }
$session = $this->sessions->createOrResume($user, $package, array_merge( $session = $this->sessions->createOrResume($user, $package, array_merge(
@@ -46,7 +46,7 @@ class PaddleCheckoutController extends Controller
] ]
)); ));
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY);
$now = now(); $now = now();
@@ -59,44 +59,10 @@ class PaddleCheckoutController extends Controller
])->save(); ])->save();
$couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? ''))); $couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? '')));
$discountId = null;
if ($couponCode !== '') { if ($couponCode !== '') {
$preview = $this->coupons->preview($couponCode, $package, $tenant); $preview = $this->coupons->preview($couponCode, $package, $tenant);
$this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']); $this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
$discountId = $preview['coupon']->paddle_discount_id;
}
if ($request->boolean('inline') && $discountId === null) {
$metadata = array_merge($session->provider_metadata ?? [], [
'mode' => 'inline',
]);
$session->forceFill([
'provider_metadata' => $metadata,
])->save();
return response()->json([
'checkout_session_id' => $session->id,
'mode' => 'inline',
'items' => [
[
'priceId' => $package->paddle_price_id,
'quantity' => 1,
],
],
'custom_data' => [
'tenant_id' => (string) $tenant->id,
'package_id' => (string) $package->id,
'checkout_session_id' => (string) $session->id,
'legal_version' => $session->legal_version,
'accepted_terms' => '1',
],
'customer' => array_filter([
'email' => $user->email,
'name' => trim(($user->first_name ?? '').' '.($user->last_name ?? '')) ?: ($user->name ?? null),
]),
]);
} }
$checkout = $this->checkout->createCheckout($tenant, $package, [ $checkout = $this->checkout->createCheckout($tenant, $package, [
@@ -108,15 +74,17 @@ class PaddleCheckoutController extends Controller
'legal_version' => $session->legal_version, 'legal_version' => $session->legal_version,
'accepted_terms' => true, 'accepted_terms' => true,
], ],
'discount_id' => $discountId, 'discount_code' => $couponCode ?: null,
'customer_email' => $user?->email,
'customer_name' => trim(($user?->first_name ?? '').' '.($user?->last_name ?? '')) ?: ($user?->name ?? null),
]); ]);
$session->forceFill([ $session->forceFill([
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id, 'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paddle_checkout_id' => $checkout['id'] ?? null, 'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null, 'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null, 'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
])), ])),
])->save(); ])->save();

View File

@@ -2,35 +2,32 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\Paddle\Exceptions\PaddleException; use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\Paddle\PaddleTransactionService; use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class PaddleReturnController extends Controller class LemonSqueezyReturnController extends Controller
{ {
public function __construct(private readonly PaddleTransactionService $transactions) {} public function __construct(private readonly LemonSqueezyOrderService $orders) {}
/**
* Handle the incoming request.
*/
public function __invoke(Request $request): RedirectResponse public function __invoke(Request $request): RedirectResponse
{ {
$transactionId = $this->resolveTransactionId($request); $orderId = $this->resolveOrderId($request);
$fallback = $this->resolveFallbackUrl(); $fallback = $this->resolveFallbackUrl();
if (! $transactionId) { if (! $orderId) {
return redirect()->to($fallback); return redirect()->to($fallback);
} }
try { try {
$transaction = $this->transactions->retrieve($transactionId); $order = $this->orders->retrieve($orderId);
} catch (PaddleException $exception) { } catch (LemonSqueezyException $exception) {
Log::warning('Paddle return failed to load transaction', [ Log::warning('Lemon Squeezy return failed to load order', [
'transaction_id' => $transactionId, 'order_id' => $orderId,
'error' => $exception->getMessage(), 'error' => $exception->getMessage(),
'status' => $exception->status(), 'status' => $exception->status(),
]); ]);
@@ -38,10 +35,10 @@ class PaddleReturnController extends Controller
return redirect()->to($fallback); return redirect()->to($fallback);
} }
$customData = $this->extractCustomData($transaction); $customData = $this->extractCustomData($order);
$status = Str::lower((string) ($transaction['status'] ?? '')); $status = Str::lower((string) Arr::get($order, 'attributes.status', ''));
$successUrl = $customData['success_url'] ?? null; $successUrl = $customData['success_url'] ?? null;
$cancelUrl = $customData['cancel_url'] ?? $customData['return_url'] ?? null; $cancelUrl = $customData['return_url'] ?? null;
$target = $this->isSuccessStatus($status) ? $successUrl : $cancelUrl; $target = $this->isSuccessStatus($status) ? $successUrl : $cancelUrl;
$target = $this->resolveSafeRedirect($target, $fallback); $target = $this->resolveSafeRedirect($target, $fallback);
@@ -49,11 +46,10 @@ class PaddleReturnController extends Controller
return redirect()->to($target); return redirect()->to($target);
} }
protected function resolveTransactionId(Request $request): ?string protected function resolveOrderId(Request $request): ?string
{ {
$candidate = $request->query('_ptxn') $candidate = $request->query('order_id')
?? $request->query('ptxn') ?? $request->query('order');
?? $request->query('transaction_id');
if (! is_string($candidate) || $candidate === '') { if (! is_string($candidate) || $candidate === '') {
return null; return null;
@@ -68,33 +64,19 @@ class PaddleReturnController extends Controller
} }
/** /**
* @param array<string, mixed> $transaction * @param array<string, mixed> $order
* @return array<string, mixed> * @return array<string, mixed>
*/ */
protected function extractCustomData(array $transaction): array protected function extractCustomData(array $order): array
{ {
$customData = Arr::get($transaction, 'custom_data', []); $customData = Arr::get($order, 'attributes.custom_data', []);
if (! is_array($customData)) { return is_array($customData) ? $customData : [];
$customData = [];
}
$legacy = Arr::get($transaction, 'customData');
if (is_array($legacy)) {
$customData = array_merge($customData, $legacy);
}
$metadata = Arr::get($transaction, 'metadata');
if (is_array($metadata)) {
$customData = array_merge($customData, $metadata);
}
return $customData;
} }
protected function isSuccessStatus(string $status): bool protected function isSuccessStatus(string $status): bool
{ {
return in_array($status, ['completed', 'paid'], true); return in_array($status, ['paid', 'completed'], true);
} }
protected function resolveSafeRedirect(?string $target, string $fallback): string protected function resolveSafeRedirect(?string $target, string $fallback): string

View File

@@ -10,7 +10,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class PaddleWebhookController extends Controller class LemonSqueezyWebhookController extends Controller
{ {
public function __construct( public function __construct(
private readonly CheckoutWebhookService $webhooks, private readonly CheckoutWebhookService $webhooks,
@@ -22,7 +22,7 @@ class PaddleWebhookController extends Controller
{ {
try { try {
if (! $this->verify($request)) { if (! $this->verify($request)) {
Log::warning('Paddle webhook signature verification failed'); Log::warning('Lemon Squeezy webhook signature verification failed');
return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST); return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST);
} }
@@ -33,29 +33,27 @@ class PaddleWebhookController extends Controller
return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED); return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED);
} }
$eventType = $payload['event_type'] ?? null; $eventType = $payload['meta']['event_name'] ?? $request->headers->get('X-Event-Name');
$eventId = $payload['event_id'] ?? $payload['id'] ?? data_get($payload, 'data.id'); $eventId = $payload['meta']['event_id'] ?? $payload['data']['id'] ?? null;
$webhookEvent = $this->recorder->recordReceived( $webhookEvent = $this->recorder->recordReceived(
'paddle', 'lemonsqueezy',
$eventId ? (string) $eventId : null, $eventId ? (string) $eventId : null,
$eventType ? (string) $eventType : null, $eventType ? (string) $eventType : null,
); );
$handled = false; $handled = false;
$this->logDev('Paddle webhook received', [ $this->logDev('Lemon Squeezy webhook received', [
'event_type' => $eventType, 'event_type' => $eventType,
'checkout_id' => data_get($payload, 'data.checkout_id'), 'order_id' => data_get($payload, 'data.id'),
'transaction_id' => data_get($payload, 'data.id'), 'has_signature' => (string) $request->headers->get('X-Signature', '') !== '',
'has_billing_signature' => (string) $request->headers->get('Paddle-Signature', '') !== '',
'has_legacy_signature' => (string) $request->headers->get('Paddle-Webhook-Signature', '') !== '',
]); ]);
if ($eventType) { if ($eventType) {
$handled = $this->webhooks->handlePaddleEvent($payload); $handled = $this->webhooks->handleLemonSqueezyEvent($payload);
$handled = $this->addonWebhooks->handle($payload) || $handled; $handled = $this->addonWebhooks->handle($payload) || $handled;
} }
Log::info('Paddle webhook processed', [ Log::info('Lemon Squeezy webhook processed', [
'event_type' => $eventType, 'event_type' => $eventType,
'handled' => $handled, 'handled' => $handled,
]); ]);
@@ -71,13 +69,13 @@ class PaddleWebhookController extends Controller
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
$eventId = $this->captureWebhookException($exception); $eventId = $this->captureWebhookException($exception);
Log::error('Paddle webhook processing failed', [ Log::error('Lemon Squeezy webhook processing failed', [
'message' => $exception->getMessage(), 'message' => $exception->getMessage(),
'event_type' => (string) $request->json('event_type'), 'event_type' => (string) data_get($request->json()->all(), 'meta.event_name'),
'sentry_event_id' => $eventId, 'sentry_event_id' => $eventId,
]); ]);
$this->logDev('Paddle webhook error payload', $this->reducePayload($request->json()->all())); $this->logDev('Lemon Squeezy webhook error payload', $this->reducePayload($request->json()->all()));
if (isset($webhookEvent)) { if (isset($webhookEvent)) {
$this->recorder->markFailed($webhookEvent, $exception->getMessage()); $this->recorder->markFailed($webhookEvent, $exception->getMessage());
@@ -89,85 +87,33 @@ class PaddleWebhookController extends Controller
protected function verify(Request $request): bool protected function verify(Request $request): bool
{ {
$secret = config('paddle.webhook_secret'); $secret = config('lemonsqueezy.webhook_secret');
if (! $secret) { if (! $secret) {
// Allow processing in sandbox or when secret not configured
return true; return true;
} }
$billingSignature = (string) $request->headers->get('Paddle-Signature', ''); $signature = (string) $request->headers->get('X-Signature', '');
if ($billingSignature !== '') {
$parts = $this->parseSignatureHeader($billingSignature);
$timestamp = $parts['ts'] ?? null;
$hash = $parts['h1'] ?? null;
if (! $timestamp || ! $hash) {
$this->logDev('Paddle webhook signature missing parts', [
'has_timestamp' => (bool) $timestamp,
'has_hash' => (bool) $hash,
]);
return false;
}
$payload = $request->getContent();
$expected = hash_hmac('sha256', $timestamp.':'.$payload, $secret);
$valid = hash_equals($expected, $hash);
if (! $valid) {
$this->logDev('Paddle webhook signature mismatch (billing)', [
'timestamp' => $timestamp,
]);
}
return $valid;
}
$payload = $request->getContent();
$signature = (string) $request->headers->get('Paddle-Webhook-Signature', '');
if ($signature === '') { if ($signature === '') {
$this->logDev('Paddle webhook missing signature header', [ $this->logDev('Lemon Squeezy webhook missing signature header', [
'header' => 'Paddle-Webhook-Signature', 'header' => 'X-Signature',
]); ]);
return false; return false;
} }
$payload = $request->getContent();
$expected = hash_hmac('sha256', $payload, $secret); $expected = hash_hmac('sha256', $payload, $secret);
$valid = hash_equals($expected, $signature); $valid = hash_equals($expected, $signature);
if (! $valid) { if (! $valid) {
$this->logDev('Paddle webhook signature mismatch (legacy)', []); $this->logDev('Lemon Squeezy webhook signature mismatch', []);
} }
return $valid; return $valid;
} }
/**
* @return array<string, string>
*/
protected function parseSignatureHeader(string $header): array
{
$parts = [];
foreach (explode(',', $header) as $chunk) {
$chunk = trim($chunk);
if ($chunk === '' || ! str_contains($chunk, '=')) {
continue;
}
[$key, $value] = array_map('trim', explode('=', $chunk, 2));
if ($key !== '' && $value !== '') {
$parts[$key] = $value;
}
}
return $parts;
}
/** /**
* @param array<string, mixed> $context * @param array<string, mixed> $context
*/ */
@@ -177,7 +123,7 @@ class PaddleWebhookController extends Controller
return; return;
} }
Log::info('[PaddleWebhook] '.$message, $context); Log::info('[LemonSqueezyWebhook] '.$message, $context);
} }
/** /**
@@ -186,12 +132,11 @@ class PaddleWebhookController extends Controller
protected function reducePayload(array $payload): array protected function reducePayload(array $payload): array
{ {
return array_filter([ return array_filter([
'event_type' => $payload['event_type'] ?? null, 'event_type' => data_get($payload, 'meta.event_name'),
'transaction_id' => data_get($payload, 'data.id'), 'order_id' => data_get($payload, 'data.id'),
'checkout_id' => data_get($payload, 'data.checkout_id'), 'status' => data_get($payload, 'data.attributes.status'),
'status' => data_get($payload, 'data.status'), 'customer_id' => data_get($payload, 'data.attributes.customer_id'),
'customer_id' => data_get($payload, 'data.customer_id'), 'has_custom_data' => is_array(data_get($payload, 'meta.custom_data')),
'has_custom_data' => is_array(data_get($payload, 'data.custom_data')),
], static fn ($value) => $value !== null); ], static fn ($value) => $value !== null);
} }

View File

@@ -13,7 +13,7 @@ use App\Models\TenantPackage;
use App\Services\Checkout\CheckoutSessionService; use App\Services\Checkout\CheckoutSessionService;
use App\Services\Coupons\CouponService; use App\Services\Coupons\CouponService;
use App\Services\GiftVouchers\GiftVoucherCheckoutService; use App\Services\GiftVouchers\GiftVoucherCheckoutService;
use App\Services\Paddle\PaddleCheckoutService; use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use App\Support\CheckoutRequestContext; use App\Support\CheckoutRequestContext;
use App\Support\CheckoutRoutes; use App\Support\CheckoutRoutes;
use App\Support\Concerns\PresentsPackages; use App\Support\Concerns\PresentsPackages;
@@ -41,7 +41,7 @@ class MarketingController extends Controller
public function __construct( public function __construct(
private readonly CheckoutSessionService $checkoutSessions, private readonly CheckoutSessionService $checkoutSessions,
private readonly PaddleCheckoutService $paddleCheckout, private readonly LemonSqueezyCheckoutService $lemonsqueezyCheckout,
private readonly CouponService $coupons, private readonly CouponService $coupons,
private readonly GiftVoucherCheckoutService $giftVouchers, private readonly GiftVoucherCheckoutService $giftVouchers,
) {} ) {}
@@ -194,14 +194,14 @@ class MarketingController extends Controller
return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned')); return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned'));
} }
if (! $package->paddle_price_id) { if (! $package->lemonsqueezy_variant_id) {
Log::warning('Package missing Paddle price id', ['package_id' => $package->id]); Log::warning('Package missing Lemon Squeezy variant id', ['package_id' => $package->id]);
return redirect()->route('packages', [ return redirect()->route('packages', [
'locale' => app()->getLocale(), 'locale' => app()->getLocale(),
'highlight' => $package->slug, 'highlight' => $package->slug,
]) ])
->with('error', __('marketing.packages.paddle_not_configured')); ->with('error', __('marketing.packages.lemonsqueezy_not_configured'));
} }
$session = $this->checkoutSessions->createOrResume($user, $package, array_merge( $session = $this->checkoutSessions->createOrResume($user, $package, array_merge(
@@ -211,7 +211,7 @@ class MarketingController extends Controller
] ]
)); ));
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); $this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY);
$now = now(); $now = now();
@@ -223,20 +223,17 @@ class MarketingController extends Controller
'legal_version' => $this->resolveLegalVersion(), 'legal_version' => $this->resolveLegalVersion(),
])->save(); ])->save();
$appliedDiscountId = null;
if ($couponCode) { if ($couponCode) {
try { try {
$preview = $this->coupons->preview($couponCode, $package, $tenant); $preview = $this->coupons->preview($couponCode, $package, $tenant);
$this->checkoutSessions->applyCoupon($session, $preview['coupon'], $preview['pricing']); $this->checkoutSessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
$appliedDiscountId = $preview['coupon']->paddle_discount_id;
$request->session()->forget('marketing.checkout.coupon'); $request->session()->forget('marketing.checkout.coupon');
} catch (ValidationException $exception) { } catch (ValidationException $exception) {
$request->session()->flash('coupon_error', $exception->errors()['code'][0] ?? __('marketing.coupon.errors.generic')); $request->session()->flash('coupon_error', $exception->errors()['code'][0] ?? __('marketing.coupon.errors.generic'));
} }
} }
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [ $checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, [
'success_url' => route('marketing.success', [ 'success_url' => route('marketing.success', [
'locale' => app()->getLocale(), 'locale' => app()->getLocale(),
'packageId' => $package->id, 'packageId' => $package->id,
@@ -252,15 +249,15 @@ class MarketingController extends Controller
'accepted_terms' => (bool) $session->accepted_terms_at, 'accepted_terms' => (bool) $session->accepted_terms_at,
'accepted_waiver' => $requiresWaiver && (bool) $session->digital_content_waiver_at, 'accepted_waiver' => $requiresWaiver && (bool) $session->digital_content_waiver_at,
], ],
'discount_id' => $appliedDiscountId, 'discount_code' => $couponCode,
]); ]);
$session->forceFill([ $session->forceFill([
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id, 'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paddle_checkout_id' => $checkout['id'] ?? null, 'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null, 'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null, 'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
])), ])),
])->save(); ])->save();
@@ -268,7 +265,7 @@ class MarketingController extends Controller
if (! $redirectUrl) { if (! $redirectUrl) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'paddle' => __('marketing.packages.paddle_checkout_failed'), 'lemonsqueezy' => __('marketing.packages.lemonsqueezy_checkout_failed'),
]); ]);
} }

View File

@@ -61,7 +61,7 @@ class TestCheckoutController extends Controller
]); ]);
} }
public function simulatePaddle( public function simulateLemonSqueezy(
Request $request, Request $request,
CheckoutWebhookService $webhooks, CheckoutWebhookService $webhooks,
CheckoutSession $session CheckoutSession $session
@@ -70,13 +70,13 @@ class TestCheckoutController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'event_type' => ['nullable', 'string'], 'event_type' => ['nullable', 'string'],
'transaction_id' => ['nullable', 'string'], 'order_id' => ['nullable', 'string'],
'status' => ['nullable', 'string'], 'status' => ['nullable', 'string'],
'checkout_id' => ['nullable', 'string'], 'checkout_id' => ['nullable', 'string'],
'metadata' => ['nullable', 'array'], 'metadata' => ['nullable', 'array'],
]); ]);
$eventType = $validated['event_type'] ?? 'transaction.completed'; $eventType = $validated['event_type'] ?? 'order_created';
$metadata = array_merge([ $metadata = array_merge([
'tenant_id' => $session->tenant_id, 'tenant_id' => $session->tenant_id,
'package_id' => $session->package_id, 'package_id' => $session->package_id,
@@ -84,16 +84,21 @@ class TestCheckoutController extends Controller
], $validated['metadata'] ?? []); ], $validated['metadata'] ?? []);
$payload = [ $payload = [
'event_type' => $eventType, 'meta' => [
'data' => array_filter([ 'event_name' => $eventType,
'id' => $validated['transaction_id'] ?? ('txn_'.Str::uuid()),
'status' => $validated['status'] ?? 'completed',
'custom_data' => $metadata, 'custom_data' => $metadata,
'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['paddle_checkout_id'] ?? 'chk_'.Str::uuid(), ],
'data' => array_filter([
'id' => $validated['order_id'] ?? ('order_'.Str::uuid()),
'attributes' => array_filter([
'status' => $validated['status'] ?? 'paid',
'checkout_id' => $validated['checkout_id'] ?? $session->provider_metadata['lemonsqueezy_checkout_id'] ?? 'chk_'.Str::uuid(),
'custom_data' => $metadata,
]),
]), ]),
]; ];
$handled = $webhooks->handlePaddleEvent($payload); $handled = $webhooks->handleLemonSqueezyEvent($payload);
return response()->json([ return response()->json([
'data' => [ 'data' => [

View File

@@ -7,7 +7,7 @@ use App\Models\EventPackage;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\Tenant; use App\Models\Tenant;
use App\Notifications\Customer\WithdrawalConfirmed; use App\Notifications\Customer\WithdrawalConfirmed;
use App\Services\Paddle\PaddleTransactionService; use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -36,7 +36,7 @@ class WithdrawalController extends Controller
public function confirm( public function confirm(
WithdrawalConfirmRequest $request, WithdrawalConfirmRequest $request,
PaddleTransactionService $transactions, LemonSqueezyOrderService $orders,
string $locale string $locale
): RedirectResponse { ): RedirectResponse {
$user = $request->user(); $user = $request->user();
@@ -60,10 +60,10 @@ class WithdrawalController extends Controller
->with('error', __('marketing.withdrawal.errors.not_eligible', [], $locale)); ->with('error', __('marketing.withdrawal.errors.not_eligible', [], $locale));
} }
$transactionId = $this->resolveTransactionId($purchase); $orderId = $this->resolveOrderId($purchase);
if (! $transactionId) { if (! $orderId) {
Log::warning('Withdrawal missing Paddle transaction reference.', [ Log::warning('Withdrawal missing Lemon Squeezy order reference.', [
'purchase_id' => $purchase->id, 'purchase_id' => $purchase->id,
'provider' => $purchase->provider, 'provider' => $purchase->provider,
]); ]);
@@ -74,11 +74,11 @@ class WithdrawalController extends Controller
} }
try { try {
$transactions->refund($transactionId, ['reason' => 'withdrawal']); $orders->refund($orderId, ['reason' => 'withdrawal']);
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
Log::warning('Withdrawal refund failed', [ Log::warning('Withdrawal refund failed', [
'purchase_id' => $purchase->id, 'purchase_id' => $purchase->id,
'transaction_id' => $transactionId, 'order_id' => $orderId,
'error' => $exception->getMessage(), 'error' => $exception->getMessage(),
]); ]);
@@ -94,13 +94,13 @@ class WithdrawalController extends Controller
$withdrawalMeta = array_merge($withdrawalMeta, [ $withdrawalMeta = array_merge($withdrawalMeta, [
'confirmed_at' => $confirmedAt->toIso8601String(), 'confirmed_at' => $confirmedAt->toIso8601String(),
'confirmed_by' => $user?->id, 'confirmed_by' => $user?->id,
'transaction_id' => $transactionId, 'order_id' => $orderId,
]); ]);
$metadata['withdrawal'] = $withdrawalMeta; $metadata['withdrawal'] = $withdrawalMeta;
$purchase->forceFill([ $purchase->forceFill([
'provider_id' => $transactionId, 'provider_id' => $orderId,
'refunded' => true, 'refunded' => true,
'metadata' => $metadata, 'metadata' => $metadata,
])->save(); ])->save();
@@ -127,7 +127,7 @@ class WithdrawalController extends Controller
->with('package') ->with('package')
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('type', 'endcustomer_event') ->where('type', 'endcustomer_event')
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('refunded', false) ->where('refunded', false)
->orderByDesc('purchased_at') ->orderByDesc('purchased_at')
->orderByDesc('id') ->orderByDesc('id')
@@ -151,7 +151,7 @@ class WithdrawalController extends Controller
$reasons[] = 'type'; $reasons[] = 'type';
} }
if ($purchase->provider !== 'paddle') { if ($purchase->provider !== 'lemonsqueezy') {
$reasons[] = 'provider'; $reasons[] = 'provider';
} }
@@ -159,7 +159,7 @@ class WithdrawalController extends Controller
$reasons[] = 'refunded'; $reasons[] = 'refunded';
} }
if (! $this->resolveTransactionId($purchase)) { if (! $this->resolveOrderId($purchase)) {
$reasons[] = 'missing_reference'; $reasons[] = 'missing_reference';
} }
@@ -224,13 +224,13 @@ class WithdrawalController extends Controller
]; ];
} }
private function resolveTransactionId(PackagePurchase $purchase): ?string private function resolveOrderId(PackagePurchase $purchase): ?string
{ {
if ($purchase->provider === 'paddle' && $purchase->provider_id) { if ($purchase->provider === 'lemonsqueezy' && $purchase->provider_id) {
return (string) $purchase->provider_id; return (string) $purchase->provider_id;
} }
return data_get($purchase->metadata, 'paddle_transaction_id'); return data_get($purchase->metadata, 'lemonsqueezy_order_id');
} }
private function deactivateTenantPackage(Tenant $tenant, PackagePurchase $purchase): void private function deactivateTenantPackage(Tenant $tenant, PackagePurchase $purchase): void

View File

@@ -37,7 +37,7 @@ class ContentSecurityPolicy
$scriptSources = [ $scriptSources = [
"'self'", "'self'",
"'nonce-{$scriptNonce}'", "'nonce-{$scriptNonce}'",
'https://cdn.paddle.com', 'https://app.lemonsqueezy.com',
'https://global.localizecdn.com', 'https://global.localizecdn.com',
]; ];
@@ -49,21 +49,16 @@ class ContentSecurityPolicy
$connectSources = [ $connectSources = [
"'self'", "'self'",
'https://api.paddle.com', 'https://api.lemonsqueezy.com',
'https://sandbox-api.paddle.com', 'https://app.lemonsqueezy.com',
'https://checkout.paddle.com', 'https://fotospiel.lemonsqueezy.com',
'https://sandbox-checkout.paddle.com',
'https://checkout-service.paddle.com',
'https://sandbox-checkout-service.paddle.com',
'https://global.localizecdn.com', 'https://global.localizecdn.com',
]; ];
$frameSources = [ $frameSources = [
"'self'", "'self'",
'https://checkout.paddle.com', 'https://app.lemonsqueezy.com',
'https://sandbox-checkout.paddle.com', 'https://fotospiel.lemonsqueezy.com',
'https://checkout-service.paddle.com',
'https://sandbox-checkout-service.paddle.com',
]; ];
$imgSources = [ $imgSources = [

View File

@@ -14,6 +14,6 @@ class VerifyCsrfToken extends Middleware
protected $except = [ protected $except = [
'api/v1/photos/*/like', 'api/v1/photos/*/like',
'api/v1/events/*/upload', 'api/v1/events/*/upload',
'paddle/webhook*', 'lemonsqueezy/webhook*',
]; ];
} }

View File

@@ -35,16 +35,16 @@ class CheckoutSessionConfirmRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'transaction_id' => ['nullable', 'string', 'required_without:checkout_id'], 'order_id' => ['nullable', 'string', 'required_without:checkout_id'],
'checkout_id' => ['nullable', 'string', 'required_without:transaction_id'], 'checkout_id' => ['nullable', 'string', 'required_without:order_id'],
]; ];
} }
public function messages(): array public function messages(): array
{ {
return [ return [
'transaction_id.required_without' => 'Transaction ID oder Checkout ID fehlt.', 'order_id.required_without' => 'Order ID oder Checkout ID fehlt.',
'checkout_id.required_without' => 'Checkout ID oder Transaction ID fehlt.', 'checkout_id.required_without' => 'Checkout ID oder Order ID fehlt.',
]; ];
} }
} }

View File

@@ -1,17 +1,17 @@
<?php <?php
namespace App\Http\Requests\Paddle; namespace App\Http\Requests\LemonSqueezy;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
class PaddleCheckoutRequest extends FormRequest class LemonSqueezyCheckoutRequest extends FormRequest
{ {
/** /**
* Determine if the user is authorized to make this request. * Determine if the user is authorized to make this request.
*/ */
public function authorize(): bool public function authorize(): bool
{ {
return true; return (bool) $this->user();
} }
/** /**
@@ -25,15 +25,11 @@ class PaddleCheckoutRequest extends FormRequest
'package_id' => ['required', 'exists:packages,id'], 'package_id' => ['required', 'exists:packages,id'],
'success_url' => ['nullable', 'url'], 'success_url' => ['nullable', 'url'],
'return_url' => ['nullable', 'url'], 'return_url' => ['nullable', 'url'],
'inline' => ['sometimes', 'boolean'],
'coupon_code' => ['nullable', 'string', 'max:64'], 'coupon_code' => ['nullable', 'string', 'max:64'],
'accepted_terms' => ['required', 'boolean', 'accepted'], 'accepted_terms' => ['required', 'boolean', 'accepted'],
]; ];
} }
/**
* Get custom validation messages.
*/
public function messages(): array public function messages(): array
{ {
return [ return [

View File

@@ -19,7 +19,7 @@ class SupportTenantResourceRequest extends SupportResourceFormRequest
Rule::unique('tenants', 'slug')->ignore($tenantId), Rule::unique('tenants', 'slug')->ignore($tenantId),
], ],
'contact_email' => ['sometimes', 'email', 'max:255'], 'contact_email' => ['sometimes', 'email', 'max:255'],
'paddle_customer_id' => ['sometimes', 'nullable', 'string', 'max:191'], 'lemonsqueezy_customer_id' => ['sometimes', 'nullable', 'string', 'max:191'],
'is_active' => ['sometimes', 'boolean'], 'is_active' => ['sometimes', 'boolean'],
'is_suspended' => ['sometimes', 'boolean'], 'is_suspended' => ['sometimes', 'boolean'],
'features' => ['sometimes', 'array'], 'features' => ['sometimes', 'array'],
@@ -31,7 +31,7 @@ class SupportTenantResourceRequest extends SupportResourceFormRequest
return [ return [
'slug', 'slug',
'contact_email', 'contact_email',
'paddle_customer_id', 'lemonsqueezy_customer_id',
'is_active', 'is_active',
'is_suspended', 'is_suspended',
'features', 'features',

View File

@@ -212,7 +212,7 @@ class EventResource extends JsonResource
'key' => $addon->addon_key, 'key' => $addon->addon_key,
'label' => $addon->metadata['label'] ?? null, 'label' => $addon->metadata['label'] ?? null,
'status' => $addon->status, 'status' => $addon->status,
'price_id' => $addon->price_id, 'variant_id' => $addon->variant_id,
'transaction_id' => $addon->transaction_id, 'transaction_id' => $addon->transaction_id,
'extra_photos' => (int) $addon->extra_photos, 'extra_photos' => (int) $addon->extra_photos,
'extra_guests' => (int) $addon->extra_guests, 'extra_guests' => (int) $addon->extra_guests,

View File

@@ -3,7 +3,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\Package; use App\Models\Package;
use App\Services\Paddle\PaddleCatalogService; use App\Services\LemonSqueezy\LemonSqueezyCatalogService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@@ -13,7 +13,7 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Throwable; use Throwable;
class PullPackageFromPaddle implements ShouldQueue class PullPackageFromLemonSqueezy implements ShouldQueue
{ {
use Dispatchable; use Dispatchable;
use InteractsWithQueue; use InteractsWithQueue;
@@ -22,7 +22,7 @@ class PullPackageFromPaddle implements ShouldQueue
public function __construct(private readonly int $packageId) {} public function __construct(private readonly int $packageId) {}
public function handle(PaddleCatalogService $catalog): void public function handle(LemonSqueezyCatalogService $catalog): void
{ {
$package = Package::query()->find($this->packageId); $package = Package::query()->find($this->packageId);
@@ -30,8 +30,8 @@ class PullPackageFromPaddle implements ShouldQueue
return; return;
} }
if (! $package->paddle_product_id && ! $package->paddle_price_id) { if (! $package->lemonsqueezy_product_id && ! $package->lemonsqueezy_variant_id) {
Log::channel('paddle-sync')->warning('Paddle pull skipped for package without linkage', [ Log::channel('lemonsqueezy-sync')->warning('Lemon Squeezy pull skipped for package without linkage', [
'package_id' => $package->id, 'package_id' => $package->id,
]); ]);
@@ -39,41 +39,41 @@ class PullPackageFromPaddle implements ShouldQueue
} }
try { try {
$product = $package->paddle_product_id ? $catalog->fetchProduct($package->paddle_product_id) : null; $product = $package->lemonsqueezy_product_id ? $catalog->fetchProduct($package->lemonsqueezy_product_id) : null;
$price = $package->paddle_price_id ? $catalog->fetchPrice($package->paddle_price_id) : null; $price = $package->lemonsqueezy_variant_id ? $catalog->fetchPrice($package->lemonsqueezy_variant_id) : null;
$snapshot = $package->paddle_snapshot ?? []; $snapshot = $package->lemonsqueezy_snapshot ?? [];
$snapshot['remote'] = array_filter([ $snapshot['remote'] = array_filter([
'product' => $product, 'product' => $product,
'price' => $price, 'price' => $price,
], static fn ($value) => $value !== null); ], static fn ($value) => $value !== null);
$package->forceFill([ $package->forceFill([
'paddle_sync_status' => 'pulled', 'lemonsqueezy_sync_status' => 'pulled',
'paddle_synced_at' => now(), 'lemonsqueezy_synced_at' => now(),
'paddle_snapshot' => $snapshot, 'lemonsqueezy_snapshot' => $snapshot,
])->save(); ])->save();
Log::channel('paddle-sync')->info('Paddle package pull completed', [ Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy package pull completed', [
'package_id' => $package->id, 'package_id' => $package->id,
]); ]);
} catch (Throwable $exception) { } catch (Throwable $exception) {
Log::channel('paddle-sync')->error('Paddle package pull failed', [ Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy package pull failed', [
'package_id' => $package->id, 'package_id' => $package->id,
'message' => $exception->getMessage(), 'message' => $exception->getMessage(),
'exception' => $exception, 'exception' => $exception,
]); ]);
$snapshot = $package->paddle_snapshot ?? []; $snapshot = $package->lemonsqueezy_snapshot ?? [];
$snapshot['error'] = array_merge(Arr::get($snapshot, 'error', []), [ $snapshot['error'] = array_merge(Arr::get($snapshot, 'error', []), [
'message' => $exception->getMessage(), 'message' => $exception->getMessage(),
'class' => $exception::class, 'class' => $exception::class,
]); ]);
$package->forceFill([ $package->forceFill([
'paddle_sync_status' => 'pull-failed', 'lemonsqueezy_sync_status' => 'pull-failed',
'paddle_synced_at' => now(), 'lemonsqueezy_synced_at' => now(),
'paddle_snapshot' => $snapshot, 'lemonsqueezy_snapshot' => $snapshot,
])->save(); ])->save();
throw $exception; throw $exception;

View File

@@ -3,8 +3,8 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\Coupon; use App\Models\Coupon;
use App\Services\Paddle\Exceptions\PaddleException; use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\Paddle\PaddleDiscountService; use App\Services\LemonSqueezy\LemonSqueezyDiscountService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@@ -12,7 +12,7 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class SyncCouponToPaddle implements ShouldQueue class SyncCouponToLemonSqueezy implements ShouldQueue
{ {
use Dispatchable; use Dispatchable;
use InteractsWithQueue; use InteractsWithQueue;
@@ -24,16 +24,16 @@ class SyncCouponToPaddle implements ShouldQueue
public bool $archive = false, public bool $archive = false,
) {} ) {}
public function handle(PaddleDiscountService $discounts): void public function handle(LemonSqueezyDiscountService $discounts): void
{ {
try { try {
if ($this->archive) { if ($this->archive) {
$discounts->archiveDiscount($this->coupon); $discounts->archiveDiscount($this->coupon);
$this->coupon->forceFill([ $this->coupon->forceFill([
'paddle_discount_id' => null, 'lemonsqueezy_discount_id' => null,
'paddle_snapshot' => null, 'lemonsqueezy_snapshot' => null,
'paddle_last_synced_at' => now(), 'lemonsqueezy_last_synced_at' => now(),
])->save(); ])->save();
return; return;
@@ -42,12 +42,12 @@ class SyncCouponToPaddle implements ShouldQueue
$data = $discounts->updateDiscount($this->coupon); $data = $discounts->updateDiscount($this->coupon);
$this->coupon->forceFill([ $this->coupon->forceFill([
'paddle_discount_id' => $data['id'] ?? $this->coupon->paddle_discount_id, 'lemonsqueezy_discount_id' => $data['id'] ?? $this->coupon->lemonsqueezy_discount_id,
'paddle_snapshot' => $data, 'lemonsqueezy_snapshot' => $data,
'paddle_last_synced_at' => now(), 'lemonsqueezy_last_synced_at' => now(),
])->save(); ])->save();
} catch (PaddleException $exception) { } catch (LemonSqueezyException $exception) {
Log::channel('paddle-sync')->error('Failed syncing coupon to Paddle', [ Log::channel('lemonsqueezy-sync')->error('Failed syncing coupon to Lemon Squeezy', [
'coupon_id' => $this->coupon->id, 'coupon_id' => $this->coupon->id,
'message' => $exception->getMessage(), 'message' => $exception->getMessage(),
'status' => $exception->status(), 'status' => $exception->status(),
@@ -55,7 +55,7 @@ class SyncCouponToPaddle implements ShouldQueue
]); ]);
$this->coupon->forceFill([ $this->coupon->forceFill([
'paddle_snapshot' => [ 'lemonsqueezy_snapshot' => [
'error' => $exception->getMessage(), 'error' => $exception->getMessage(),
'status' => $exception->status(), 'status' => $exception->status(),
'context' => $exception->context(), 'context' => $exception->context(),

View File

@@ -3,8 +3,8 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\PackageAddon; use App\Models\PackageAddon;
use App\Services\Paddle\Exceptions\PaddleException; use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\Paddle\PaddleAddonCatalogService; use App\Services\LemonSqueezy\LemonSqueezyAddonCatalogService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@@ -14,7 +14,7 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Throwable; use Throwable;
class SyncPackageAddonToPaddle implements ShouldQueue class SyncPackageAddonToLemonSqueezy implements ShouldQueue
{ {
use Dispatchable; use Dispatchable;
use InteractsWithQueue; use InteractsWithQueue;
@@ -26,7 +26,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue
*/ */
public function __construct(private readonly int $addonId, private readonly array $options = []) {} public function __construct(private readonly int $addonId, private readonly array $options = []) {}
public function handle(PaddleAddonCatalogService $catalog): void public function handle(LemonSqueezyAddonCatalogService $catalog): void
{ {
$addon = PackageAddon::query()->find($this->addonId); $addon = PackageAddon::query()->find($this->addonId);
@@ -39,7 +39,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue
$priceOverrides = Arr::get($this->options, 'price', []); $priceOverrides = Arr::get($this->options, 'price', []);
if ($dryRun) { if ($dryRun) {
$this->storeDryRunSnapshot($catalog, $addon, $productOverrides, $priceOverrides); $this->storeDryRunSnapshot($addon, $productOverrides, $priceOverrides);
return; return;
} }
@@ -47,41 +47,41 @@ class SyncPackageAddonToPaddle implements ShouldQueue
// Mark syncing (metadata) // Mark syncing (metadata)
$addon->forceFill([ $addon->forceFill([
'metadata' => array_merge($addon->metadata ?? [], [ 'metadata' => array_merge($addon->metadata ?? [], [
'paddle_sync_status' => 'syncing', 'lemonsqueezy_sync_status' => 'syncing',
'paddle_synced_at' => now()->toIso8601String(), 'lemonsqueezy_synced_at' => now()->toIso8601String(),
]), ]),
])->save(); ])->save();
try { try {
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides); $payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
$productResponse = $addon->metadata['paddle_product_id'] ?? null $productResponse = $addon->metadata['lemonsqueezy_product_id'] ?? null
? $catalog->updateProduct($addon->metadata['paddle_product_id'], $addon, $payloadOverrides['product']) ? $catalog->updateProduct($addon->metadata['lemonsqueezy_product_id'], $addon, $payloadOverrides['product'])
: $catalog->createProduct($addon, $payloadOverrides['product']); : $catalog->createProduct($addon, $payloadOverrides['product']);
$productId = (string) ($productResponse['id'] ?? $addon->metadata['paddle_product_id'] ?? null); $productId = (string) ($productResponse['id'] ?? $addon->metadata['lemonsqueezy_product_id'] ?? null);
if (! $productId) { if (! $productId) {
throw new PaddleException('Paddle product ID missing after addon sync.'); throw new LemonSqueezyException('Lemon Squeezy product ID missing after addon sync.');
} }
$priceResponse = $addon->price_id $priceResponse = $addon->variant_id
? $catalog->updatePrice($addon->price_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId])) ? $catalog->updatePrice($addon->variant_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId]))
: $catalog->createPrice($addon, $productId, $payloadOverrides['price']); : $catalog->createPrice($addon, $productId, $payloadOverrides['price']);
$priceId = (string) ($priceResponse['id'] ?? $addon->price_id); $priceId = (string) ($priceResponse['id'] ?? $addon->variant_id);
if (! $priceId) { if (! $priceId) {
throw new PaddleException('Paddle price ID missing after addon sync.'); throw new LemonSqueezyException('Lemon Squeezy variant ID missing after addon sync.');
} }
$addon->forceFill([ $addon->forceFill([
'price_id' => $priceId, 'variant_id' => $priceId,
'metadata' => array_merge($addon->metadata ?? [], [ 'metadata' => array_merge($addon->metadata ?? [], [
'paddle_sync_status' => 'synced', 'lemonsqueezy_sync_status' => 'synced',
'paddle_synced_at' => now()->toIso8601String(), 'lemonsqueezy_synced_at' => now()->toIso8601String(),
'paddle_product_id' => $productId, 'lemonsqueezy_product_id' => $productId,
'paddle_snapshot' => [ 'lemonsqueezy_snapshot' => [
'product' => $productResponse, 'product' => $productResponse,
'price' => $priceResponse, 'price' => $priceResponse,
'payload' => $payloadOverrides, 'payload' => $payloadOverrides,
@@ -89,7 +89,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue
]), ]),
])->save(); ])->save();
} catch (Throwable $exception) { } catch (Throwable $exception) {
Log::channel('paddle-sync')->error('Paddle addon sync failed', [ Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy addon sync failed', [
'addon_id' => $addon->id, 'addon_id' => $addon->id,
'message' => $exception->getMessage(), 'message' => $exception->getMessage(),
'exception' => $exception, 'exception' => $exception,
@@ -97,9 +97,9 @@ class SyncPackageAddonToPaddle implements ShouldQueue
$addon->forceFill([ $addon->forceFill([
'metadata' => array_merge($addon->metadata ?? [], [ 'metadata' => array_merge($addon->metadata ?? [], [
'paddle_sync_status' => 'failed', 'lemonsqueezy_sync_status' => 'failed',
'paddle_synced_at' => now()->toIso8601String(), 'lemonsqueezy_synced_at' => now()->toIso8601String(),
'paddle_error' => [ 'lemonsqueezy_error' => [
'message' => $exception->getMessage(), 'message' => $exception->getMessage(),
'class' => $exception::class, 'class' => $exception::class,
], ],
@@ -145,22 +145,22 @@ class SyncPackageAddonToPaddle implements ShouldQueue
* @param array<string, mixed> $productOverrides * @param array<string, mixed> $productOverrides
* @param array<string, mixed> $priceOverrides * @param array<string, mixed> $priceOverrides
*/ */
protected function storeDryRunSnapshot(PaddleCatalogService $catalog, PackageAddon $addon, array $productOverrides, array $priceOverrides): void protected function storeDryRunSnapshot(PackageAddon $addon, array $productOverrides, array $priceOverrides): void
{ {
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides); $payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
$addon->forceFill([ $addon->forceFill([
'metadata' => array_merge($addon->metadata ?? [], [ 'metadata' => array_merge($addon->metadata ?? [], [
'paddle_sync_status' => 'dry-run', 'lemonsqueezy_sync_status' => 'dry-run',
'paddle_synced_at' => now()->toIso8601String(), 'lemonsqueezy_synced_at' => now()->toIso8601String(),
'paddle_snapshot' => [ 'lemonsqueezy_snapshot' => [
'dry_run' => true, 'dry_run' => true,
'payload' => $payloadOverrides, 'payload' => $payloadOverrides,
], ],
]), ]),
])->save(); ])->save();
Log::channel('paddle-sync')->info('Paddle addon dry-run snapshot generated', [ Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy addon dry-run snapshot generated', [
'addon_id' => $addon->id, 'addon_id' => $addon->id,
]); ]);
} }

View File

@@ -3,8 +3,8 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\Package; use App\Models\Package;
use App\Services\Paddle\Exceptions\PaddleException; use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\Paddle\PaddleCatalogService; use App\Services\LemonSqueezy\LemonSqueezyCatalogService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@@ -14,7 +14,7 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Throwable; use Throwable;
class SyncPackageToPaddle implements ShouldQueue class SyncPackageToLemonSqueezy implements ShouldQueue
{ {
use Dispatchable; use Dispatchable;
use InteractsWithQueue; use InteractsWithQueue;
@@ -26,7 +26,7 @@ class SyncPackageToPaddle implements ShouldQueue
*/ */
public function __construct(private readonly int $packageId, private readonly array $options = []) {} public function __construct(private readonly int $packageId, private readonly array $options = []) {}
public function handle(PaddleCatalogService $catalog): void public function handle(LemonSqueezyCatalogService $catalog): void
{ {
$package = Package::query()->find($this->packageId); $package = Package::query()->find($this->packageId);
@@ -45,37 +45,37 @@ class SyncPackageToPaddle implements ShouldQueue
} }
$package->forceFill([ $package->forceFill([
'paddle_sync_status' => 'syncing', 'lemonsqueezy_sync_status' => 'syncing',
])->save(); ])->save();
try { try {
$productResponse = $package->paddle_product_id $productResponse = $package->lemonsqueezy_product_id
? $catalog->updateProduct($package->paddle_product_id, $package, $productOverrides) ? $catalog->updateProduct($package->lemonsqueezy_product_id, $package, $productOverrides)
: $catalog->createProduct($package, $productOverrides); : $catalog->createProduct($package, $productOverrides);
$productId = (string) ($productResponse['id'] ?? $package->paddle_product_id); $productId = (string) ($productResponse['id'] ?? $package->lemonsqueezy_product_id);
if (! $productId) { if (! $productId) {
throw new PaddleException('Paddle product ID missing after sync.'); throw new LemonSqueezyException('Lemon Squeezy product ID missing after sync.');
} }
$package->paddle_product_id = $productId; $package->lemonsqueezy_product_id = $productId;
$priceResponse = $package->paddle_price_id $priceResponse = $package->lemonsqueezy_variant_id
? $catalog->updatePrice($package->paddle_price_id, $package, array_merge($priceOverrides, ['product_id' => $productId])) ? $catalog->updatePrice($package->lemonsqueezy_variant_id, $package, array_merge($priceOverrides, ['product_id' => $productId]))
: $catalog->createPrice($package, $productId, $priceOverrides); : $catalog->createPrice($package, $productId, $priceOverrides);
$priceId = (string) ($priceResponse['id'] ?? $package->paddle_price_id); $priceId = (string) ($priceResponse['id'] ?? $package->lemonsqueezy_variant_id);
if (! $priceId) { if (! $priceId) {
throw new PaddleException('Paddle price ID missing after sync.'); throw new LemonSqueezyException('Lemon Squeezy variant ID missing after sync.');
} }
$package->forceFill([ $package->forceFill([
'paddle_price_id' => $priceId, 'lemonsqueezy_variant_id' => $priceId,
'paddle_sync_status' => 'synced', 'lemonsqueezy_sync_status' => 'synced',
'paddle_synced_at' => now(), 'lemonsqueezy_synced_at' => now(),
'paddle_snapshot' => [ 'lemonsqueezy_snapshot' => [
'product' => $productResponse, 'product' => $productResponse,
'price' => $priceResponse, 'price' => $priceResponse,
'payload' => [ 'payload' => [
@@ -85,16 +85,16 @@ class SyncPackageToPaddle implements ShouldQueue
], ],
])->save(); ])->save();
} catch (Throwable $exception) { } catch (Throwable $exception) {
Log::channel('paddle-sync')->error('Paddle package sync failed', [ Log::channel('lemonsqueezy-sync')->error('Lemon Squeezy package sync failed', [
'package_id' => $package->id, 'package_id' => $package->id,
'message' => $exception->getMessage(), 'message' => $exception->getMessage(),
'exception' => $exception, 'exception' => $exception,
]); ]);
$package->forceFill([ $package->forceFill([
'paddle_sync_status' => 'failed', 'lemonsqueezy_sync_status' => 'failed',
'paddle_synced_at' => now(), 'lemonsqueezy_synced_at' => now(),
'paddle_snapshot' => array_merge($package->paddle_snapshot ?? [], [ 'lemonsqueezy_snapshot' => array_merge($package->lemonsqueezy_snapshot ?? [], [
'error' => [ 'error' => [
'message' => $exception->getMessage(), 'message' => $exception->getMessage(),
'class' => $exception::class, 'class' => $exception::class,
@@ -110,19 +110,19 @@ class SyncPackageToPaddle implements ShouldQueue
* @param array<string, mixed> $productOverrides * @param array<string, mixed> $productOverrides
* @param array<string, mixed> $priceOverrides * @param array<string, mixed> $priceOverrides
*/ */
protected function storeDryRunSnapshot(PaddleCatalogService $catalog, Package $package, array $productOverrides, array $priceOverrides): void protected function storeDryRunSnapshot(LemonSqueezyCatalogService $catalog, Package $package, array $productOverrides, array $priceOverrides): void
{ {
$productPayload = $catalog->buildProductPayload($package, $productOverrides); $productPayload = $catalog->buildProductPayload($package, $productOverrides);
$pricePayload = $catalog->buildPricePayload( $pricePayload = $catalog->buildPricePayload(
$package, $package,
$package->paddle_product_id ?: ($priceOverrides['product_id'] ?? 'pending'), $package->lemonsqueezy_product_id ?: ($priceOverrides['product_id'] ?? 'pending'),
$priceOverrides $priceOverrides
); );
$package->forceFill([ $package->forceFill([
'paddle_sync_status' => 'dry-run', 'lemonsqueezy_sync_status' => 'dry-run',
'paddle_synced_at' => now(), 'lemonsqueezy_synced_at' => now(),
'paddle_snapshot' => [ 'lemonsqueezy_snapshot' => [
'dry_run' => true, 'dry_run' => true,
'payload' => [ 'payload' => [
'product' => $productPayload, 'product' => $productPayload,
@@ -131,7 +131,7 @@ class SyncPackageToPaddle implements ShouldQueue
], ],
])->save(); ])->save();
Log::channel('paddle-sync')->info('Paddle package dry-run snapshot generated', [ Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy package dry-run snapshot generated', [
'package_id' => $package->id, 'package_id' => $package->id,
]); ]);
} }

View File

@@ -60,7 +60,7 @@ class PurchaseConfirmation extends Mailable
private function formattedTotal(): string private function formattedTotal(): string
{ {
$totals = $this->purchase->metadata['paddle_totals'] ?? []; $totals = $this->purchase->metadata['lemonsqueezy_totals'] ?? [];
$currency = $totals['currency'] $currency = $totals['currency']
?? $this->purchase->metadata['currency'] ?? $this->purchase->metadata['currency']
?? $this->purchase->package?->currency ?? $this->purchase->package?->currency
@@ -113,7 +113,7 @@ class PurchaseConfirmation extends Mailable
private function providerLabel(): string private function providerLabel(): string
{ {
$provider = $this->purchase->provider ?? 'paddle'; $provider = $this->purchase->provider ?? 'lemonsqueezy';
$labelKey = 'emails.purchase.provider.'.$provider; $labelKey = 'emails.purchase.provider.'.$provider;
$label = __($labelKey); $label = __($labelKey);

View File

@@ -30,7 +30,7 @@ class CheckoutSession extends Model
public const PROVIDER_NONE = 'none'; public const PROVIDER_NONE = 'none';
public const PROVIDER_PADDLE = 'paddle'; public const PROVIDER_LEMONSQUEEZY = 'lemonsqueezy';
public const PROVIDER_FREE = 'free'; public const PROVIDER_FREE = 'free';

View File

@@ -38,10 +38,10 @@ class Coupon extends Model
'metadata', 'metadata',
'starts_at', 'starts_at',
'ends_at', 'ends_at',
'paddle_discount_id', 'lemonsqueezy_discount_id',
'paddle_mode', 'lemonsqueezy_mode',
'paddle_snapshot', 'lemonsqueezy_snapshot',
'paddle_last_synced_at', 'lemonsqueezy_last_synced_at',
'created_by', 'created_by',
'updated_by', 'updated_by',
]; ];
@@ -54,10 +54,10 @@ class Coupon extends Model
'enabled_for_checkout' => 'boolean', 'enabled_for_checkout' => 'boolean',
'auto_apply' => 'boolean', 'auto_apply' => 'boolean',
'metadata' => 'array', 'metadata' => 'array',
'paddle_snapshot' => 'array', 'lemonsqueezy_snapshot' => 'array',
'starts_at' => 'datetime', 'starts_at' => 'datetime',
'ends_at' => 'datetime', 'ends_at' => 'datetime',
'paddle_last_synced_at' => 'datetime', 'lemonsqueezy_last_synced_at' => 'datetime',
]; ];
protected static function booted(): void protected static function booted(): void

View File

@@ -23,7 +23,7 @@ class CouponRedemption extends Model
'package_id', 'package_id',
'tenant_id', 'tenant_id',
'user_id', 'user_id',
'paddle_transaction_id', 'lemonsqueezy_order_id',
'status', 'status',
'failure_reason', 'failure_reason',
'ip_address', 'ip_address',

View File

@@ -20,7 +20,7 @@ class EventPackageAddon extends Model
'extra_photos', 'extra_photos',
'extra_guests', 'extra_guests',
'extra_gallery_days', 'extra_gallery_days',
'price_id', 'variant_id',
'checkout_id', 'checkout_id',
'transaction_id', 'transaction_id',
'status', 'status',

View File

@@ -32,9 +32,9 @@ class GiftVoucher extends Model
'recipient_email', 'recipient_email',
'recipient_name', 'recipient_name',
'message', 'message',
'paddle_transaction_id', 'lemonsqueezy_order_id',
'paddle_checkout_id', 'lemonsqueezy_checkout_id',
'paddle_price_id', 'lemonsqueezy_variant_id',
'coupon_id', 'coupon_id',
'expires_at', 'expires_at',
'redeemed_at', 'redeemed_at',

View File

@@ -33,11 +33,11 @@ class Package extends Model
'description', 'description',
'description_translations', 'description_translations',
'description_table', 'description_table',
'paddle_product_id', 'lemonsqueezy_product_id',
'paddle_price_id', 'lemonsqueezy_variant_id',
'paddle_sync_status', 'lemonsqueezy_sync_status',
'paddle_synced_at', 'lemonsqueezy_synced_at',
'paddle_snapshot', 'lemonsqueezy_snapshot',
]; ];
protected $casts = [ protected $casts = [
@@ -54,8 +54,8 @@ class Package extends Model
'name_translations' => 'array', 'name_translations' => 'array',
'description_translations' => 'array', 'description_translations' => 'array',
'description_table' => 'array', 'description_table' => 'array',
'paddle_synced_at' => 'datetime', 'lemonsqueezy_synced_at' => 'datetime',
'paddle_snapshot' => 'array', 'lemonsqueezy_snapshot' => 'array',
]; ];
protected $appends = [ protected $appends = [
@@ -146,20 +146,20 @@ class Package extends Model
]; ];
} }
public function getPaddleSyncErrorMessageAttribute(): ?string public function getLemonSqueezySyncErrorMessageAttribute(): ?string
{ {
$message = data_get($this->paddle_snapshot, 'error.message'); $message = data_get($this->lemonsqueezy_snapshot, 'error.message');
return is_string($message) && $message !== '' ? $message : null; return is_string($message) && $message !== '' ? $message : null;
} }
public function linkPaddleIds(string $productId, string $priceId): void public function linkLemonSqueezyIds(string $productId, string $variantId): void
{ {
$this->forceFill([ $this->forceFill([
'paddle_product_id' => $productId, 'lemonsqueezy_product_id' => $productId,
'paddle_price_id' => $priceId, 'lemonsqueezy_variant_id' => $variantId,
'paddle_sync_status' => 'linked', 'lemonsqueezy_sync_status' => 'linked',
'paddle_synced_at' => now(), 'lemonsqueezy_synced_at' => now(),
])->save(); ])->save();
} }

View File

@@ -13,7 +13,7 @@ class PackageAddon extends Model
protected $fillable = [ protected $fillable = [
'key', 'key',
'label', 'label',
'price_id', 'variant_id',
'extra_photos', 'extra_photos',
'extra_guests', 'extra_guests',
'extra_gallery_days', 'extra_gallery_days',

View File

@@ -16,7 +16,7 @@ class TenantPackage extends Model
protected $fillable = [ protected $fillable = [
'tenant_id', 'tenant_id',
'package_id', 'package_id',
'paddle_subscription_id', 'lemonsqueezy_subscription_id',
'price', 'price',
'purchased_at', 'purchased_at',
'expires_at', 'expires_at',

View File

@@ -187,8 +187,8 @@ class AppServiceProvider extends ServiceProvider
]; ];
}); });
RateLimiter::for('paddle-webhook', function (Request $request) { RateLimiter::for('lemonsqueezy-webhook', function (Request $request) {
return Limit::perMinute(30)->by('paddle:'.$request->ip()); return Limit::perMinute(30)->by('lemonsqueezy:'.$request->ip());
}); });
RateLimiter::for('gift-lookup', function (Request $request) { RateLimiter::for('gift-lookup', function (Request $request) {

View File

@@ -19,7 +19,7 @@ class EventAddonCatalog
->mapWithKeys(function (PackageAddon $addon) { ->mapWithKeys(function (PackageAddon $addon) {
return [$addon->key => [ return [$addon->key => [
'label' => $addon->label, 'label' => $addon->label,
'price_id' => $addon->price_id, 'variant_id' => $addon->variant_id,
'increments' => $addon->increments, 'increments' => $addon->increments,
]]; ]];
}) })
@@ -39,11 +39,11 @@ class EventAddonCatalog
return $this->all()[$key] ?? null; return $this->all()[$key] ?? null;
} }
public function resolvePriceId(string $key): ?string public function resolveVariantId(string $key): ?string
{ {
$addon = $this->find($key); $addon = $this->find($key);
return $addon['price_id'] ?? null; return $addon['variant_id'] ?? null;
} }
/** /**

View File

@@ -5,20 +5,16 @@ namespace App\Services\Addons;
use App\Models\Event; use App\Models\Event;
use App\Models\EventPackageAddon; use App\Models\EventPackageAddon;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Paddle\PaddleClient; use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use App\Services\Paddle\PaddleCustomerService;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Throwable;
class EventAddonCheckoutService class EventAddonCheckoutService
{ {
public function __construct( public function __construct(
private readonly EventAddonCatalog $catalog, private readonly EventAddonCatalog $catalog,
private readonly PaddleClient $paddle, private readonly LemonSqueezyCheckoutService $checkout,
private readonly PaddleCustomerService $customers,
) {} ) {}
/** /**
@@ -32,25 +28,17 @@ class EventAddonCheckoutService
$acceptedWaiver = (bool) ($payload['accepted_waiver'] ?? false); $acceptedWaiver = (bool) ($payload['accepted_waiver'] ?? false);
$acceptedTerms = (bool) ($payload['accepted_terms'] ?? false); $acceptedTerms = (bool) ($payload['accepted_terms'] ?? false);
try {
$customerId = $this->customers->ensureCustomerId($tenant);
} catch (Throwable $exception) {
throw ValidationException::withMessages([
'customer' => __('Konnte Paddle-Kundenkonto nicht anlegen: :message', ['message' => $exception->getMessage()]),
]);
}
if (! $addonKey || ! $this->catalog->find($addonKey)) { if (! $addonKey || ! $this->catalog->find($addonKey)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'addon_key' => __('Unbekanntes Add-on.'), 'addon_key' => __('Unbekanntes Add-on.'),
]); ]);
} }
$priceId = $this->catalog->resolvePriceId($addonKey); $variantId = $this->catalog->resolveVariantId($addonKey);
if (! $priceId) { if (! $variantId) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'addon_key' => __('Für dieses Add-on ist kein Paddle-Preis hinterlegt.'), 'addon_key' => __('Für dieses Add-on ist kein Lemon Squeezy Variant hinterlegt.'),
]); ]);
} }
@@ -73,6 +61,7 @@ class EventAddonCheckoutService
'addon_key' => $addonKey, 'addon_key' => $addonKey,
'addon_intent' => $addonIntent, 'addon_intent' => $addonIntent,
'quantity' => $quantity, 'quantity' => $quantity,
'lemonsqueezy_variant_id' => $variantId,
'legal_version' => $this->resolveLegalVersion(), 'legal_version' => $this->resolveLegalVersion(),
'accepted_terms' => $acceptedTerms ? '1' : '0', 'accepted_terms' => $acceptedTerms ? '1' : '0',
'accepted_waiver' => $acceptedWaiver ? '1' : '0', 'accepted_waiver' => $acceptedWaiver ? '1' : '0',
@@ -80,31 +69,18 @@ class EventAddonCheckoutService
'cancel_url' => $payload['cancel_url'] ?? null, 'cancel_url' => $payload['cancel_url'] ?? null,
], static fn ($value) => $value !== null && $value !== ''); ], static fn ($value) => $value !== null && $value !== '');
$requestPayload = array_filter([ $response = $this->checkout->createVariantCheckout($variantId, $metadata, [
'customer_id' => $customerId, 'success_url' => $payload['success_url'] ?? null,
'items' => [ 'return_url' => $payload['cancel_url'] ?? null,
[ 'customer_email' => $tenant->contact_email ?? $tenant->user?->email,
'price_id' => $priceId, ]);
'quantity' => $quantity,
],
],
'custom_data' => $metadata,
], static fn ($value) => $value !== null && $value !== '');
$response = $this->paddle->post('/transactions', $requestPayload); $checkoutUrl = $response['checkout_url'] ?? null;
$checkoutId = $response['id'] ?? null;
$checkoutUrl = Arr::get($response, 'data.checkout.url') $transactionId = null;
?? Arr::get($response, 'checkout.url')
?? Arr::get($response, 'data.url')
?? Arr::get($response, 'url');
$checkoutId = Arr::get($response, 'data.checkout_id')
?? Arr::get($response, 'data.checkout.id')
?? Arr::get($response, 'checkout_id')
?? Arr::get($response, 'checkout.id');
$transactionId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
if (! $checkoutUrl) { if (! $checkoutUrl) {
Log::warning('Paddle addon checkout response missing url', ['response' => $response]); Log::warning('Lemon Squeezy addon checkout response missing url', ['response' => $response]);
} }
EventPackageAddon::create([ EventPackageAddon::create([
@@ -113,7 +89,7 @@ class EventAddonCheckoutService
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'addon_key' => $addonKey, 'addon_key' => $addonKey,
'quantity' => $quantity, 'quantity' => $quantity,
'price_id' => $priceId, 'variant_id' => $variantId,
'checkout_id' => $checkoutId, 'checkout_id' => $checkoutId,
'transaction_id' => $transactionId, 'transaction_id' => $transactionId,
'status' => 'pending', 'status' => 'pending',
@@ -133,10 +109,8 @@ class EventAddonCheckoutService
return [ return [
'checkout_url' => $checkoutUrl, 'checkout_url' => $checkoutUrl,
'expires_at' => Arr::get($response, 'data.checkout.expires_at') 'expires_at' => $response['expires_at'] ?? null,
?? Arr::get($response, 'data.expires_at') 'id' => $checkoutId,
?? Arr::get($response, 'expires_at'),
'id' => $transactionId ?? $checkoutId,
]; ];
} }

View File

@@ -17,14 +17,19 @@ class EventAddonWebhookService
public function handle(array $payload): bool public function handle(array $payload): bool
{ {
$eventType = $payload['event_type'] ?? null; $eventType = $payload['meta']['event_name'] ?? null;
$data = $payload['data'] ?? []; $data = $payload['data'] ?? [];
if ($eventType !== 'transaction.completed') { if (! in_array($eventType, ['order_created', 'order_updated'], true)) {
return false; return false;
} }
$metadata = $this->extractMetadata($data); $status = strtolower((string) data_get($data, 'attributes.status', ''));
if ($status !== 'paid') {
return false;
}
$metadata = $this->extractMetadata($payload);
$intentId = $metadata['addon_intent'] ?? null; $intentId = $metadata['addon_intent'] ?? null;
$addonKey = $metadata['addon_key'] ?? null; $addonKey = $metadata['addon_key'] ?? null;
@@ -32,8 +37,8 @@ class EventAddonWebhookService
return false; return false;
} }
$transactionId = $data['id'] ?? $data['transaction_id'] ?? null; $transactionId = $data['id'] ?? null;
$checkoutId = $data['checkout_id'] ?? null; $checkoutId = data_get($data, 'attributes.checkout_id') ?? null;
$addon = EventPackageAddon::query() $addon = EventPackageAddon::query()
->where('addon_key', $addonKey) ->where('addon_key', $addonKey)
@@ -66,10 +71,12 @@ class EventAddonWebhookService
'transaction_id' => $transactionId, 'transaction_id' => $transactionId,
'checkout_id' => $addon->checkout_id ?: $checkoutId, 'checkout_id' => $addon->checkout_id ?: $checkoutId,
'status' => 'completed', 'status' => 'completed',
'amount' => Arr::get($data, 'totals.grand_total') ?? Arr::get($data, 'amount'), 'amount' => $this->resolveAmount($data),
'currency' => Arr::get($data, 'currency_code') ?? Arr::get($data, 'currency'), 'currency' => Arr::get($data, 'attributes.currency') ?? Arr::get($data, 'currency'),
'metadata' => array_merge($addon->metadata ?? [], ['webhook_payload' => $data]), 'metadata' => array_merge($addon->metadata ?? [], ['webhook_payload' => $data]),
'receipt_payload' => Arr::get($data, 'receipt_url') ? ['receipt_url' => Arr::get($data, 'receipt_url')] : null, 'receipt_payload' => Arr::get($data, 'attributes.urls.receipt')
? ['receipt_url' => Arr::get($data, 'attributes.urls.receipt')]
: null,
'purchased_at' => now(), 'purchased_at' => now(),
])->save(); ])->save();
@@ -118,17 +125,36 @@ class EventAddonWebhookService
{ {
$metadata = []; $metadata = [];
if (isset($data['metadata']) && is_array($data['metadata'])) { if (isset($data['meta']['custom_data']) && is_array($data['meta']['custom_data'])) {
$metadata = $data['metadata']; $metadata = $data['meta']['custom_data'];
} }
if (isset($data['custom_data']) && is_array($data['custom_data'])) { if (isset($data['metadata']) && is_array($data['metadata'])) {
$metadata = array_merge($metadata, $data['custom_data']); $metadata = array_merge($metadata, $data['metadata']);
}
if (isset($data['attributes']['custom_data']) && is_array($data['attributes']['custom_data'])) {
$metadata = array_merge($metadata, $data['attributes']['custom_data']);
} }
return $metadata; return $metadata;
} }
private function resolveAmount(array $data): ?float
{
$total = Arr::get($data, 'attributes.total');
if ($total === null || $total === '') {
return null;
}
if (! is_numeric($total)) {
return null;
}
return round(((float) $total) / 100, 2);
}
/** /**
* @return array<string, int> * @return array<string, int>
*/ */

View File

@@ -60,16 +60,16 @@ class CheckoutAssignmentService
$consents = array_filter($consents); $consents = array_filter($consents);
$providerReference = $options['provider_reference'] $providerReference = $options['provider_reference']
?? $metadata['paddle_transaction_id'] ?? null ?? $metadata['lemonsqueezy_order_id'] ?? null
?? $metadata['paddle_checkout_id'] ?? null ?? $metadata['lemonsqueezy_checkout_id'] ?? null
?? CheckoutSession::PROVIDER_FREE; ?? CheckoutSession::PROVIDER_FREE;
$providerName = $options['provider'] $providerName = $options['provider']
?? $session->provider ?? $session->provider
?? ($metadata['paddle_transaction_id'] ?? $metadata['paddle_checkout_id'] ? CheckoutSession::PROVIDER_PADDLE : null) ?? ($metadata['lemonsqueezy_order_id'] ?? $metadata['lemonsqueezy_checkout_id'] ? CheckoutSession::PROVIDER_LEMONSQUEEZY : null)
?? CheckoutSession::PROVIDER_FREE; ?? CheckoutSession::PROVIDER_FREE;
$totals = $this->resolvePaddleTotals($session, $options['payload'] ?? []); $totals = $this->resolveLemonSqueezyTotals($session, $options['payload'] ?? []);
$currency = $totals['currency'] ?? $session->currency ?? $package->currency ?? 'EUR'; $currency = $totals['currency'] ?? $session->currency ?? $package->currency ?? 'EUR';
$price = array_key_exists('total', $totals) ? $totals['total'] : (float) $session->amount_total; $price = array_key_exists('total', $totals) ? $totals['total'] : (float) $session->amount_total;
@@ -88,7 +88,7 @@ class CheckoutAssignmentService
'payload' => $options['payload'] ?? null, 'payload' => $options['payload'] ?? null,
'checkout_session_id' => $session->id, 'checkout_session_id' => $session->id,
'consents' => $consents ?: null, 'consents' => $consents ?: null,
'paddle_totals' => $totals !== [] ? $totals : null, 'lemonsqueezy_totals' => $totals !== [] ? $totals : null,
'currency' => $currency, 'currency' => $currency,
], static fn ($value) => $value !== null && $value !== ''), ], static fn ($value) => $value !== null && $value !== ''),
] ]
@@ -223,34 +223,25 @@ class CheckoutAssignmentService
* @param array<string, mixed> $payload * @param array<string, mixed> $payload
* @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float} * @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float}
*/ */
protected function resolvePaddleTotals(CheckoutSession $session, array $payload): array protected function resolveLemonSqueezyTotals(CheckoutSession $session, array $payload): array
{ {
$metadataTotals = $session->provider_metadata['paddle_totals'] ?? null; $metadataTotals = $session->provider_metadata['lemonsqueezy_totals'] ?? null;
if (is_array($metadataTotals) && $metadataTotals !== []) { if (is_array($metadataTotals) && $metadataTotals !== []) {
return $metadataTotals; return $metadataTotals;
} }
$totals = Arr::get($payload, 'details.totals', Arr::get($payload, 'totals', [])); $attributes = Arr::get($payload, 'attributes', []);
if (! is_array($totals) || $totals === []) { if (! is_array($attributes) || $attributes === []) {
return []; return [];
} }
$currency = Arr::get($totals, 'currency_code') $currency = Arr::get($attributes, 'currency');
?? Arr::get($payload, 'currency_code')
?? Arr::get($totals, 'currency')
?? Arr::get($payload, 'currency');
$subtotal = $this->convertMinorAmount(Arr::get($totals, 'subtotal.amount', $totals['subtotal'] ?? null)); $subtotal = $this->convertMinorAmount(Arr::get($attributes, 'subtotal'));
$discount = $this->convertMinorAmount(Arr::get($totals, 'discount.amount', $totals['discount'] ?? null)); $discount = $this->convertMinorAmount(Arr::get($attributes, 'discount_total'));
$tax = $this->convertMinorAmount(Arr::get($totals, 'tax.amount', $totals['tax'] ?? null)); $tax = $this->convertMinorAmount(Arr::get($attributes, 'tax'));
$total = $this->convertMinorAmount( $total = $this->convertMinorAmount(Arr::get($attributes, 'total'));
Arr::get(
$totals,
'total.amount',
$totals['total'] ?? Arr::get($totals, 'grand_total.amount', $totals['grand_total'] ?? null)
)
);
return array_filter([ return array_filter([
'currency' => $currency ? strtoupper((string) $currency) : null, 'currency' => $currency ? strtoupper((string) $currency) : null,

View File

@@ -72,8 +72,8 @@ class CheckoutSessionService
$session->amount_discount = 0; $session->amount_discount = 0;
$session->provider = CheckoutSession::PROVIDER_NONE; $session->provider = CheckoutSession::PROVIDER_NONE;
$session->status = CheckoutSession::STATUS_DRAFT; $session->status = CheckoutSession::STATUS_DRAFT;
$session->paddle_checkout_id = null; $session->lemonsqueezy_checkout_id = null;
$session->paddle_transaction_id = null; $session->lemonsqueezy_order_id = null;
$session->provider_metadata = []; $session->provider_metadata = [];
$session->failure_reason = null; $session->failure_reason = null;
$session->coupon()->dissociate(); $session->coupon()->dissociate();
@@ -118,7 +118,7 @@ class CheckoutSessionService
$provider = strtolower($provider); $provider = strtolower($provider);
if (! in_array($provider, [ if (! in_array($provider, [
CheckoutSession::PROVIDER_PADDLE, CheckoutSession::PROVIDER_LEMONSQUEEZY,
CheckoutSession::PROVIDER_FREE, CheckoutSession::PROVIDER_FREE,
], true)) { ], true)) {
throw new RuntimeException("Unsupported checkout provider [{$provider}]"); throw new RuntimeException("Unsupported checkout provider [{$provider}]");

View File

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

View File

@@ -31,7 +31,7 @@ class CouponRedemptionService
return; return;
} }
$transactionId = Arr::get($payload, 'id') ?? $session->paddle_transaction_id; $transactionId = Arr::get($payload, 'id') ?? $session->lemonsqueezy_order_id;
$context = $this->resolveRequestContext($session); $context = $this->resolveRequestContext($session);
$fraudSnapshot = $this->buildFraudSnapshot($context); $fraudSnapshot = $this->buildFraudSnapshot($context);
@@ -40,7 +40,7 @@ class CouponRedemptionService
'tenant_id' => $session->tenant_id, 'tenant_id' => $session->tenant_id,
'user_id' => $session->user_id, 'user_id' => $session->user_id,
'package_id' => $session->package_id, 'package_id' => $session->package_id,
'paddle_transaction_id' => $transactionId, 'lemonsqueezy_order_id' => $transactionId,
'status' => CouponRedemption::STATUS_SUCCESS, 'status' => CouponRedemption::STATUS_SUCCESS,
'failure_reason' => null, 'failure_reason' => null,
'amount_discounted' => $session->amount_discount, 'amount_discounted' => $session->amount_discount,
@@ -84,7 +84,7 @@ class CouponRedemptionService
'tenant_id' => $session->tenant_id, 'tenant_id' => $session->tenant_id,
'user_id' => $session->user_id, 'user_id' => $session->user_id,
'package_id' => $session->package_id, 'package_id' => $session->package_id,
'paddle_transaction_id' => $session->paddle_transaction_id, 'lemonsqueezy_order_id' => $session->lemonsqueezy_order_id,
'status' => CouponRedemption::STATUS_FAILED, 'status' => CouponRedemption::STATUS_FAILED,
'failure_reason' => $reason, 'failure_reason' => $reason,
'amount_discounted' => $session->amount_discount, 'amount_discounted' => $session->amount_discount,

View File

@@ -8,16 +8,12 @@ use App\Models\Coupon;
use App\Models\CouponRedemption; use App\Models\CouponRedemption;
use App\Models\Package; use App\Models\Package;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleDiscountService;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class CouponService class CouponService
{ {
public function __construct(private readonly PaddleDiscountService $paddleDiscounts) {} public function __construct() {}
/** /**
* @return array{coupon: Coupon, pricing: array<string, mixed>, source: string} * @return array{coupon: Coupon, pricing: array<string, mixed>, source: string}
@@ -39,7 +35,7 @@ class CouponService
public function ensureCouponCanBeApplied(Coupon $coupon, Package $package, ?Tenant $tenant = null): void public function ensureCouponCanBeApplied(Coupon $coupon, Package $package, ?Tenant $tenant = null): void
{ {
if (! $coupon->paddle_discount_id) { if (! $coupon->lemonsqueezy_discount_id) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.not_synced'), 'code' => __('marketing.coupon.errors.not_synced'),
]); ]);
@@ -124,58 +120,12 @@ class CouponService
$currency = Str::upper($package->currency ?? 'EUR'); $currency = Str::upper($package->currency ?? 'EUR');
$subtotal = (float) $package->price; $subtotal = (float) $package->price;
if ($package->paddle_price_id) {
try {
$preview = $this->paddleDiscounts->previewDiscount(
$coupon,
[
[
'price_id' => $package->paddle_price_id,
'quantity' => 1,
],
],
array_filter([
'currency' => $currency,
'customer_id' => $tenant?->paddle_customer_id,
])
);
$mapped = $this->mapPaddlePreview($preview, $currency, $subtotal);
return [
'pricing' => $mapped,
'source' => 'paddle',
];
} catch (PaddleException $exception) {
Log::warning('Paddle preview failed, falling back to manual pricing', [
'coupon_id' => $coupon->id,
'package_id' => $package->id,
'message' => $exception->getMessage(),
]);
}
}
return [ return [
'pricing' => $this->manualPricing($coupon, $currency, $subtotal), 'pricing' => $this->manualPricing($coupon, $currency, $subtotal),
'source' => 'manual', 'source' => 'manual',
]; ];
} }
protected function mapPaddlePreview(array $preview, string $currency, float $fallbackSubtotal): array
{
$totals = $this->extractTotals($preview);
$subtotal = $totals['subtotal'] ?? $fallbackSubtotal;
$discount = $totals['discount'] ?? 0.0;
$tax = $totals['tax'] ?? 0.0;
$total = $totals['total'] ?? max($subtotal - $discount + $tax, 0);
return $this->formatPricing($currency, $subtotal, $discount, $tax, $total, [
'raw' => $preview,
'breakdown' => $totals['breakdown'] ?? [],
]);
}
protected function manualPricing(Coupon $coupon, string $currency, float $subtotal): array protected function manualPricing(Coupon $coupon, string $currency, float $subtotal): array
{ {
$discount = match ($coupon->type) { $discount = match ($coupon->type) {
@@ -199,42 +149,6 @@ class CouponService
]); ]);
} }
protected function extractTotals(array $preview): array
{
$totals = Arr::get($preview, 'totals', Arr::get($preview, 'details.totals', []));
$subtotal = $this->convertMinorAmount($totals['subtotal'] ?? ($totals['subtotal']['amount'] ?? null));
$discount = $this->convertMinorAmount($totals['discount'] ?? ($totals['discount']['amount'] ?? null));
$tax = $this->convertMinorAmount($totals['tax'] ?? ($totals['tax']['amount'] ?? null));
$total = $this->convertMinorAmount($totals['total'] ?? ($totals['total']['amount'] ?? null));
return array_filter([
'currency' => $totals['currency_code'] ?? Arr::get($preview, 'currency_code'),
'subtotal' => $subtotal,
'discount' => $discount,
'tax' => $tax,
'total' => $total,
'breakdown' => Arr::get($preview, 'discounts', []),
], static fn ($value) => $value !== null && $value !== '');
}
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 formatPricing(string $currency, float $subtotal, float $discount, float $tax, float $total, array $extra = []): array protected function formatPricing(string $currency, float $subtotal, float $discount, float $tax, float $total, array $extra = []): array
{ {
$locale = $this->mapLocale(app()->getLocale()); $locale = $this->mapLocale(app()->getLocale());

View File

@@ -2,34 +2,32 @@
namespace App\Services\GiftVouchers; namespace App\Services\GiftVouchers;
use App\Services\Paddle\Exceptions\PaddleException; use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use App\Services\Paddle\PaddleClient;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class GiftVoucherCheckoutService 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 public function tiers(): array
{ {
return collect(config('gift-vouchers.tiers', [])) return collect(config('gift-vouchers.tiers', []))
->map(function (array $tier): array { ->map(function (array $tier): array {
$currency = Str::upper($tier['currency'] ?? 'EUR'); $currency = Str::upper($tier['currency'] ?? 'EUR');
$priceId = $tier['paddle_price_id'] ?? null; $variantId = $tier['lemonsqueezy_variant_id'] ?? null;
return [ return [
'key' => $tier['key'], 'key' => $tier['key'],
'label' => $tier['label'], 'label' => $tier['label'],
'amount' => (float) $tier['amount'], 'amount' => (float) $tier['amount'],
'currency' => $currency, 'currency' => $currency,
'paddle_price_id' => $priceId, 'lemonsqueezy_variant_id' => $variantId,
'can_checkout' => ! empty($priceId), 'can_checkout' => ! empty($variantId),
]; ];
}) })
->values() ->values()
@@ -44,47 +42,34 @@ class GiftVoucherCheckoutService
{ {
$tier = $this->findTier($data['tier_key']); $tier = $this->findTier($data['tier_key']);
if (! $tier || empty($tier['paddle_price_id'])) { if (! $tier || empty($tier['lemonsqueezy_variant_id'])) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'tier_key' => __('Gift voucher is not available right now.'), '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 = [ return $this->checkout->createVariantCheckout(
'items' => [ (string) $tier['lemonsqueezy_variant_id'],
[ $customData,
'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(),
'success_url' => $data['success_url'] ?? null, 'success_url' => $data['success_url'] ?? null,
'cancel_url' => $data['return_url'] ?? null, 'return_url' => $data['return_url'] ?? null,
]), 'customer_email' => $data['purchaser_email'],
]; ]
);
$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'),
];
} }
/** /**
@@ -105,43 +90,4 @@ class GiftVoucherCheckoutService
return $tier; 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\CouponStatus;
use App\Enums\CouponType; use App\Enums\CouponType;
use App\Jobs\NotifyGiftVoucherReminder; use App\Jobs\NotifyGiftVoucherReminder;
use App\Jobs\SyncCouponToPaddle;
use App\Mail\GiftVoucherIssued; use App\Mail\GiftVoucherIssued;
use App\Models\Coupon; use App\Models\Coupon;
use App\Models\GiftVoucher; use App\Models\GiftVoucher;
use App\Models\Package; use App\Models\Package;
use App\Services\Paddle\PaddleTransactionService; use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -21,15 +20,15 @@ use Illuminate\Validation\ValidationException;
class GiftVoucherService 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); $metadata = $this->extractCustomData($payload);
$priceId = $this->resolvePriceId($payload); $variantId = $this->resolveVariantId($payload);
$amount = $this->resolveAmount($payload); $amount = $this->resolveAmount($payload);
$currency = Str::upper($this->resolveCurrency($payload)); $currency = Str::upper($this->resolveCurrency($payload));
$locale = $metadata['app_locale'] ?? app()->getLocale(); $locale = $metadata['app_locale'] ?? app()->getLocale();
@@ -37,9 +36,10 @@ class GiftVoucherService
$expiresAt = now()->addYears((int) config('gift-vouchers.default_valid_years', 5)); $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() $existing = GiftVoucher::query()
->where('paddle_transaction_id', $payload['id']) ->where('lemonsqueezy_order_id', $orderId)
->first(); ->first();
} }
@@ -47,19 +47,19 @@ class GiftVoucherService
$voucher = GiftVoucher::query()->updateOrCreate( $voucher = GiftVoucher::query()->updateOrCreate(
[ [
'paddle_transaction_id' => $payload['id'] ?? null, 'lemonsqueezy_order_id' => $orderId,
], ],
[ [
'code' => $metadata['gift_code'] ?? $this->generateCode(), 'code' => $metadata['gift_code'] ?? $this->generateCode(),
'amount' => $amount, 'amount' => $amount,
'currency' => $currency, 'currency' => $currency,
'status' => GiftVoucher::STATUS_ISSUED, '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_email' => $metadata['recipient_email'] ?? null,
'recipient_name' => $metadata['recipient_name'] ?? null, 'recipient_name' => $metadata['recipient_name'] ?? null,
'message' => $metadata['message'] ?? null, 'message' => $metadata['message'] ?? null,
'paddle_checkout_id' => $payload['checkout_id'] ?? Arr::get($payload, 'details.checkout_id'), 'lemonsqueezy_checkout_id' => data_get($payload, 'data.attributes.checkout_id'),
'paddle_price_id' => $priceId, 'lemonsqueezy_variant_id' => $variantId,
'metadata' => $mergedMetadata, 'metadata' => $mergedMetadata,
'expires_at' => $expiresAt, 'expires_at' => $expiresAt,
'refunded_at' => null, 'refunded_at' => null,
@@ -70,7 +70,6 @@ class GiftVoucherService
if (! $voucher->coupon_id) { if (! $voucher->coupon_id) {
$coupon = $this->createCouponForVoucher($voucher); $coupon = $this->createCouponForVoucher($voucher);
$voucher->forceFill(['coupon_id' => $coupon->id])->save(); $voucher->forceFill(['coupon_id' => $coupon->id])->save();
SyncCouponToPaddle::dispatch($coupon);
} }
$notificationsSent = (bool) Arr::get($voucher->metadata ?? [], 'notifications_sent', false); $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([ 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, 'reason' => $reason,
])); ]));
@@ -172,6 +171,7 @@ class GiftVoucherService
'description' => 'Geschenkgutschein '.number_format((float) $voucher->amount, 2).' '.$voucher->currency.' für Endkunden-Pakete.', 'description' => 'Geschenkgutschein '.number_format((float) $voucher->amount, 2).' '.$voucher->currency.' für Endkunden-Pakete.',
'starts_at' => now(), 'starts_at' => now(),
'ends_at' => $voucher->expires_at, 'ends_at' => $voucher->expires_at,
'lemonsqueezy_discount_id' => $voucher->code,
]); ]);
if ($packages->isNotEmpty()) { if ($packages->isNotEmpty()) {
@@ -187,41 +187,32 @@ class GiftVoucherService
return Package::query() return Package::query()
->whereIn('type', $types) ->whereIn('type', $types)
->whereNotNull('paddle_price_id') ->whereNotNull('lemonsqueezy_variant_id')
->get(['id']); ->get(['id']);
} }
protected function resolvePriceId(array $payload): ?string protected function resolveVariantId(array $payload): ?string
{ {
$metadata = $this->extractCustomData($payload); $metadata = $this->extractCustomData($payload);
if (is_array($metadata) && ! empty($metadata['paddle_price_id'])) { if (is_array($metadata) && ! empty($metadata['lemonsqueezy_variant_id'])) {
return $metadata['paddle_price_id']; return $metadata['lemonsqueezy_variant_id'];
} }
$items = Arr::get($payload, 'items', Arr::get($payload, 'details.items', [])); return data_get($payload, 'data.attributes.variant_id');
if (is_array($items) && isset($items[0]['price_id'])) {
return $items[0]['price_id'];
}
return $payload['price_id'] ?? null;
} }
protected function resolveAmount(array $payload): float protected function resolveAmount(array $payload): float
{ {
$tiers = Collection::make(config('gift-vouchers.tiers', [])) $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); $variantId = $this->resolveVariantId($payload);
if ($priceId && $tiers->has($priceId)) { if ($variantId && $tiers->has($variantId)) {
return (float) $tiers->get($priceId)['amount']; return (float) $tiers->get($variantId)['amount'];
} }
$amount = Arr::get($payload, 'totals.grand_total.amount') $amount = data_get($payload, 'data.attributes.total');
?? 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');
if (is_numeric($amount)) { if (is_numeric($amount)) {
$value = (float) $amount; $value = (float) $amount;
@@ -236,10 +227,7 @@ class GiftVoucherService
protected function resolveCurrency(array $payload): string protected function resolveCurrency(array $payload): string
{ {
return $payload['currency_code'] return (string) (data_get($payload, 'data.attributes.currency') ?? 'EUR');
?? Arr::get($payload, 'details.totals.currency_code')
?? Arr::get($payload, 'currency')
?? 'EUR';
} }
/** /**
@@ -250,8 +238,16 @@ class GiftVoucherService
{ {
$customData = []; $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'])) { 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'])) { if (isset($payload['customData']) && is_array($payload['customData'])) {

View File

@@ -15,8 +15,8 @@ class IntegrationHealthService
public function providers(): array public function providers(): array
{ {
return [ return [
$this->buildProvider('paddle', 'Paddle', [ $this->buildProvider('lemonsqueezy', 'Lemon Squeezy', [
'is_configured' => filled(config('paddle.webhook_secret')), 'is_configured' => filled(config('lemonsqueezy.webhook_secret')),
'label' => 'Webhook secret', 'label' => 'Webhook secret',
]), ]),
$this->buildProvider('revenuecat', 'RevenueCat', [ $this->buildProvider('revenuecat', 'RevenueCat', [

View File

@@ -1,10 +1,10 @@
<?php <?php
namespace App\Services\Paddle\Exceptions; namespace App\Services\LemonSqueezy\Exceptions;
use RuntimeException; use RuntimeException;
class PaddleException extends RuntimeException class LemonSqueezyException extends RuntimeException
{ {
public function __construct(string $message, private readonly ?int $status = null, private readonly array $context = []) public function __construct(string $message, private readonly ?int $status = null, private readonly array $context = [])
{ {

View File

@@ -1,14 +1,14 @@
<?php <?php
namespace App\Services\Paddle; namespace App\Services\LemonSqueezy;
use App\Models\PackageAddon; use App\Models\PackageAddon;
use App\Services\Paddle\Exceptions\PaddleException; use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
class PaddleAddonCatalogService class LemonSqueezyAddonCatalogService
{ {
public function __construct(private readonly PaddleClient $client) {} public function __construct(private readonly LemonSqueezyClient $client) {}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
@@ -124,7 +124,7 @@ class PaddleAddonCatalogService
$metaPrice = $addon->metadata['price_eur'] ?? null; $metaPrice = $addon->metadata['price_eur'] ?? null;
if (! is_numeric($metaPrice)) { if (! is_numeric($metaPrice)) {
throw new PaddleException('No unit price specified for addon. Provide metadata[price_eur] or overrides.unit_price.'); throw new LemonSqueezyException('No unit price specified for addon. Provide metadata[price_eur] or overrides.unit_price.');
} }
$amountCents = (int) round(((float) $metaPrice) * 100); $amountCents = (int) round(((float) $metaPrice) * 100);

View File

@@ -1,14 +1,14 @@
<?php <?php
namespace App\Services\Paddle; namespace App\Services\LemonSqueezy;
use App\Models\Package; use App\Models\Package;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class PaddleCatalogService class LemonSqueezyCatalogService
{ {
public function __construct(private readonly PaddleClient $client) {} public function __construct(private readonly LemonSqueezyClient $client) {}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
@@ -63,7 +63,7 @@ class PaddleCatalogService
{ {
$payload = $this->buildPricePayload( $payload = $this->buildPricePayload(
$package, $package,
$overrides['product_id'] ?? $package->paddle_product_id, $overrides['product_id'] ?? $package->lemonsqueezy_product_id,
$overrides, $overrides,
includeProduct: false includeProduct: false
); );

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Services\LemonSqueezy;
use App\Models\Package;
use App\Models\Tenant;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
class LemonSqueezyCheckoutService
{
public function __construct(
private readonly LemonSqueezyClient $client,
) {}
/**
* @param array{success_url?: string|null, return_url?: string|null, discount_code?: string|null, metadata?: array, custom_data?: array, customer_email?: string|null, customer_name?: string|null} $options
*/
public function createCheckout(Tenant $tenant, Package $package, array $options = []): array
{
$storeId = (string) config('lemonsqueezy.store_id');
$customData = $this->buildCustomData(
$tenant,
$package,
array_merge(
$options['metadata'] ?? [],
$options['custom_data'] ?? [],
array_filter([
'success_url' => $options['success_url'] ?? null,
'return_url' => $options['return_url'] ?? null,
], static fn ($value) => $value !== null && $value !== '')
)
);
return $this->createVariantCheckout((string) $package->lemonsqueezy_variant_id, $customData, $options + [
'customer_email' => $options['customer_email'] ?? null,
'customer_name' => $options['customer_name'] ?? null,
'store_id' => $storeId,
]);
}
/**
* @param array{success_url?: string|null, return_url?: string|null, discount_code?: string|null, customer_email?: string|null, customer_name?: string|null, store_id?: string|null} $options
*/
public function createVariantCheckout(string $variantId, array $customData, array $options = []): array
{
$storeId = $options['store_id'] ?? (string) config('lemonsqueezy.store_id');
$attributes = array_filter([
'checkout_data' => array_filter([
'custom' => $customData,
'email' => $options['customer_email'] ?? null,
'name' => $options['customer_name'] ?? null,
'discount_code' => $options['discount_code'] ?? null,
], static fn ($value) => $value !== null && $value !== ''),
'checkout_options' => [
'embed' => true,
],
'product_options' => array_filter([
'redirect_url' => $options['success_url'] ?? null,
], static fn ($value) => $value !== null && $value !== ''),
'test_mode' => (bool) config('lemonsqueezy.test_mode', false),
], static fn ($value) => $value !== null && $value !== '');
$payload = [
'data' => [
'type' => 'checkouts',
'attributes' => $attributes,
'relationships' => [
'store' => [
'data' => [
'type' => 'stores',
'id' => $storeId,
],
],
'variant' => [
'data' => [
'type' => 'variants',
'id' => $variantId,
],
],
],
],
];
$response = $this->client->post('/checkouts', $payload);
$checkoutUrl = Arr::get($response, 'data.attributes.url')
?? Arr::get($response, 'data.attributes.checkout_url')
?? Arr::get($response, 'data.url')
?? Arr::get($response, 'url');
if (! $checkoutUrl) {
Log::warning('Lemon Squeezy checkout response missing url', ['response' => $response]);
}
return [
'checkout_url' => $checkoutUrl,
'expires_at' => Arr::get($response, 'data.attributes.expires_at'),
'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'),
];
}
/**
* @param array<string, mixed> $extra
* @return array<string, string>
*/
protected function buildCustomData(Tenant $tenant, Package $package, array $extra = []): array
{
$metadata = [
'tenant_id' => (string) $tenant->id,
'package_id' => (string) $package->id,
];
foreach ($extra as $key => $value) {
if (! is_string($key)) {
continue;
}
if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) {
$metadata[$key] = (string) $value;
}
}
return $metadata;
}
}

View File

@@ -1,15 +1,14 @@
<?php <?php
namespace App\Services\Paddle; namespace App\Services\LemonSqueezy;
use App\Services\Paddle\Exceptions\PaddleException; use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use Illuminate\Http\Client\Factory as HttpFactory; use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class PaddleClient class LemonSqueezyClient
{ {
public function __construct( public function __construct(
private readonly HttpFactory $http, private readonly HttpFactory $http,
@@ -42,16 +41,21 @@ class PaddleClient
try { try {
$response = $request->send(strtoupper($method), ltrim($endpoint, '/'), $options); $response = $request->send(strtoupper($method), ltrim($endpoint, '/'), $options);
} catch (RequestException $exception) { } catch (RequestException $exception) {
throw new PaddleException($exception->getMessage(), $exception->response?->status(), $exception->response?->json() ?? []); throw new LemonSqueezyException(
$exception->getMessage(),
$exception->response?->status(),
$exception->response?->json() ?? []
);
} }
if ($response->failed()) { if ($response->failed()) {
$body = $response->json() ?? []; $body = $response->json() ?? [];
$message = Arr::get($body, 'error.message') $message = Arr::get($body, 'errors.0.detail')
?? Arr::get($body, 'error')
?? Arr::get($body, 'message') ?? Arr::get($body, 'message')
?? sprintf('Paddle request failed with status %s', $response->status()); ?? sprintf('Lemon Squeezy request failed with status %s', $response->status());
throw new PaddleException($message, $response->status(), $body); throw new LemonSqueezyException($message, $response->status(), $body);
} }
return $response->json() ?? []; return $response->json() ?? [];
@@ -59,23 +63,20 @@ class PaddleClient
protected function preparedRequest(): PendingRequest protected function preparedRequest(): PendingRequest
{ {
$apiKey = config('paddle.api_key'); $apiKey = config('lemonsqueezy.api_key');
if (! $apiKey) { if (! $apiKey) {
throw new PaddleException('Paddle API key is not configured.'); throw new LemonSqueezyException('Lemon Squeezy API key is not configured.');
} }
$baseUrl = rtrim((string) config('paddle.base_url'), '/'); $baseUrl = rtrim((string) config('lemonsqueezy.base_url'), '/');
$environment = (string) config('paddle.environment', 'production');
$headers = [
'User-Agent' => sprintf('FotospielApp/%s PaddleClient', app()->version()),
'Paddle-Environment' => Str::lower($environment) === 'sandbox' ? 'sandbox' : 'production',
'Paddle-Version' => '1',
];
return $this->http return $this->http
->baseUrl($baseUrl) ->baseUrl($baseUrl)
->withHeaders($headers) ->withHeaders([
'Accept' => 'application/vnd.api+json',
'Content-Type' => 'application/vnd.api+json',
'User-Agent' => sprintf('FotospielApp/%s LemonSqueezyClient', app()->version()),
])
->withToken($apiKey) ->withToken($apiKey)
->acceptJson() ->acceptJson()
->asJson(); ->asJson();

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Services\Paddle; namespace App\Services\LemonSqueezy;
use App\Enums\CouponType; use App\Enums\CouponType;
use App\Models\Coupon; use App\Models\Coupon;
@@ -8,9 +8,9 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class PaddleDiscountService class LemonSqueezyDiscountService
{ {
public function __construct(private readonly PaddleClient $client) {} public function __construct(private readonly LemonSqueezyClient $client) {}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
@@ -34,24 +34,24 @@ class PaddleDiscountService
*/ */
public function updateDiscount(Coupon $coupon): array public function updateDiscount(Coupon $coupon): array
{ {
if (! $coupon->paddle_discount_id) { if (! $coupon->lemonsqueezy_discount_id) {
return $this->createDiscount($coupon); return $this->createDiscount($coupon);
} }
$payload = $this->buildDiscountPayload($coupon); $payload = $this->buildDiscountPayload($coupon);
$response = $this->client->patch('/discounts/'.$coupon->paddle_discount_id, $payload); $response = $this->client->patch('/discounts/'.$coupon->lemonsqueezy_discount_id, $payload);
return Arr::get($response, 'data', $response); return Arr::get($response, 'data', $response);
} }
public function archiveDiscount(Coupon $coupon): void public function archiveDiscount(Coupon $coupon): void
{ {
if (! $coupon->paddle_discount_id) { if (! $coupon->lemonsqueezy_discount_id) {
return; return;
} }
$this->client->delete('/discounts/'.$coupon->paddle_discount_id); $this->client->delete('/discounts/'.$coupon->lemonsqueezy_discount_id);
} }
/** /**
@@ -63,7 +63,7 @@ class PaddleDiscountService
{ {
$payload = [ $payload = [
'items' => $items, 'items' => $items,
'discount_id' => $coupon->paddle_discount_id, 'discount_id' => $coupon->lemonsqueezy_discount_id,
]; ];
if (isset($context['currency'])) { if (isset($context['currency'])) {
@@ -128,7 +128,7 @@ class PaddleDiscountService
'currency_code' => Str::upper((string) ($coupon->currency ?? config('app.currency', 'EUR'))), 'currency_code' => Str::upper((string) ($coupon->currency ?? config('app.currency', 'EUR'))),
'enabled_for_checkout' => $coupon->enabled_for_checkout, 'enabled_for_checkout' => $coupon->enabled_for_checkout,
'description' => $this->resolveDescription($coupon), 'description' => $this->resolveDescription($coupon),
'mode' => $coupon->paddle_mode ?? 'standard', 'mode' => $coupon->lemonsqueezy_mode ?? 'standard',
'usage_limit' => $coupon->usage_limit, 'usage_limit' => $coupon->usage_limit,
'maximum_recurring_intervals' => null, 'maximum_recurring_intervals' => null,
'recur' => false, 'recur' => false,
@@ -168,13 +168,13 @@ class PaddleDiscountService
$packages = ($coupon->relationLoaded('packages') $packages = ($coupon->relationLoaded('packages')
? $coupon->packages ? $coupon->packages
: $coupon->packages()->get()) : $coupon->packages()->get())
->whereNotNull('paddle_price_id'); ->whereNotNull('lemonsqueezy_variant_id');
if ($packages->isEmpty()) { if ($packages->isEmpty()) {
return null; return null;
} }
$prices = $packages->pluck('paddle_price_id')->filter()->values(); $prices = $packages->pluck('lemonsqueezy_variant_id')->filter()->values();
return $prices->isEmpty() ? null : $prices->all(); return $prices->isEmpty() ? null : $prices->all();
} }

View File

@@ -1,34 +1,34 @@
<?php <?php
namespace App\Services\Paddle; namespace App\Services\LemonSqueezy;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class PaddleGiftVoucherCatalogService class LemonSqueezyGiftVoucherCatalogService
{ {
public function __construct(private readonly PaddleClient $client) {} public function __construct(private readonly LemonSqueezyClient $client) {}
/** /**
* @param array{key:string,label:string,amount:float,currency?:string,paddle_product_id?:string|null,paddle_price_id?:string|null} $tier * @param array{key:string,label:string,amount:float,currency?:string,lemonsqueezy_product_id?:string|null,lemonsqueezy_variant_id?:string|null} $tier
* @return array{product_id:string,price_id:string} * @return array{product_id:string,variant_id:string}
*/ */
public function ensureTier(array $tier): array public function ensureTier(array $tier): array
{ {
$product = $tier['paddle_product_id'] ?? null; $product = $tier['lemonsqueezy_product_id'] ?? null;
$price = $tier['paddle_price_id'] ?? null; $variant = $tier['lemonsqueezy_variant_id'] ?? null;
if (! $product) { if (! $product) {
$product = $this->createProduct($tier)['id']; $product = $this->createProduct($tier)['id'];
} }
if (! $price) { if (! $variant) {
$price = $this->createPrice($tier, $product)['id']; $variant = $this->createPrice($tier, $product)['id'];
} }
return [ return [
'product_id' => $product, 'product_id' => $product,
'price_id' => $price, 'variant_id' => $variant,
]; ];
} }

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Services\LemonSqueezy;
use Illuminate\Support\Arr;
class LemonSqueezyOrderService
{
public function __construct(private readonly LemonSqueezyClient $client) {}
/**
* @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
*/
public function listForCustomer(string $customerId, array $query = []): array
{
$payload = array_filter(array_merge([
'filter[customer_id]' => $customerId,
'sort' => '-created_at',
], $query), static fn ($value) => $value !== null && $value !== '');
$response = $this->client->get('/orders', $payload);
$orders = Arr::get($response, 'data', []);
$meta = Arr::get($response, 'meta', []);
if (! is_array($orders)) {
$orders = [];
}
return [
'data' => array_map([$this, 'mapOrder'], $orders),
'meta' => $this->mapPagination($meta),
];
}
/**
* @return array<string, mixed>
*/
public function retrieve(string $orderId): array
{
$response = $this->client->get("/orders/{$orderId}");
$order = Arr::get($response, 'data');
return is_array($order) ? $order : (is_array($response) ? $response : []);
}
/**
* @return array<string, mixed>|null
*/
public function findByCheckoutId(string $checkoutId): ?array
{
$response = $this->client->get("/checkouts/{$checkoutId}");
$checkout = Arr::get($response, 'data');
if (! is_array($checkout)) {
return null;
}
$orderId = Arr::get($checkout, 'attributes.order_id');
if (! $orderId) {
return null;
}
return $this->retrieve((string) $orderId);
}
/**
* Issue a refund for a Lemon Squeezy order.
*
* @param array{reason?: string|null} $options
* @return array<string, mixed>
*/
public function refund(string $orderId, array $options = []): array
{
$payload = [
'data' => [
'type' => 'refunds',
'attributes' => array_filter([
'order_id' => $orderId,
'reason' => $options['reason'] ?? null,
], static fn ($value) => $value !== null && $value !== ''),
],
];
return $this->client->post('/refunds', $payload);
}
/**
* @param array<string, mixed> $order
* @return array<string, mixed>
*/
protected function mapOrder(array $order): array
{
$attributes = Arr::get($order, 'attributes', []);
return [
'id' => $order['id'] ?? null,
'order_number' => $attributes['order_number'] ?? null,
'status' => $attributes['status'] ?? null,
'amount' => $this->convertAmount($attributes['subtotal'] ?? null),
'currency' => $attributes['currency'] ?? 'EUR',
'origin' => 'lemonsqueezy',
'checkout_id' => $attributes['checkout_id'] ?? null,
'created_at' => $attributes['created_at'] ?? null,
'updated_at' => $attributes['updated_at'] ?? null,
'receipt_url' => Arr::get($attributes, 'urls.receipt'),
'tax' => $this->convertAmount($attributes['tax'] ?? null),
'grand_total' => $this->convertAmount($attributes['total'] ?? null),
];
}
/**
* @param array<string, mixed> $meta
* @return array<string, mixed>
*/
protected function mapPagination(array $meta): array
{
$page = Arr::get($meta, 'page', []);
$current = (int) ($page['currentPage'] ?? $page['current_page'] ?? 1);
$totalPages = (int) ($page['totalPages'] ?? $page['total_pages'] ?? 1);
return [
'next' => $current < $totalPages ? (string) ($current + 1) : null,
'previous' => $current > 1 ? (string) ($current - 1) : null,
'has_more' => $current < $totalPages,
];
}
protected function convertAmount(mixed $value): ?float
{
if ($value === null || $value === '') {
return null;
}
if (is_array($value) && isset($value['amount'])) {
$value = $value['amount'];
}
if (is_string($value)) {
$value = preg_replace('/[^0-9.-]/', '', $value);
}
if ($value === '' || $value === null) {
return null;
}
$amount = (float) $value;
return $amount / 100;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Services\LemonSqueezy;
use Illuminate\Support\Arr;
class LemonSqueezySubscriptionService
{
public function __construct(private readonly LemonSqueezyClient $client) {}
/**
* @return array<string, mixed>
*/
public function retrieve(string $subscriptionId): array
{
$response = $this->client->get("/subscriptions/{$subscriptionId}");
return Arr::get($response, 'data', is_array($response) ? $response : []);
}
/**
* @param array<string, mixed> $subscription
* @return array<string, mixed>
*/
public function customData(array $subscription): array
{
$attributes = Arr::get($subscription, 'attributes', []);
$custom = Arr::get($attributes, 'custom_data', Arr::get($attributes, 'custom', []));
return is_array($custom) ? $custom : [];
}
public function portalUrl(array $subscription): ?string
{
return Arr::get($subscription, 'attributes.urls.customer_portal')
?? Arr::get($subscription, 'attributes.urls.customer_portal_url');
}
public function updatePaymentMethodUrl(array $subscription): ?string
{
return Arr::get($subscription, 'attributes.urls.update_payment_method')
?? Arr::get($subscription, 'attributes.urls.update_payment_method_url');
}
}

View File

@@ -1,95 +0,0 @@
<?php
namespace App\Services\Paddle;
use App\Models\Package;
use App\Models\Tenant;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
class PaddleCheckoutService
{
public function __construct(
private readonly PaddleClient $client,
private readonly PaddleCustomerService $customers,
) {}
/**
* @param array{success_url?: string|null, return_url?: string|null, discount_id?: string|null, metadata?: array, custom_data?: array} $options
*/
public function createCheckout(Tenant $tenant, Package $package, array $options = []): array
{
$customerId = $this->customers->ensureCustomerId($tenant);
$customData = $this->buildMetadata(
$tenant,
$package,
array_merge(
$options['metadata'] ?? [],
$options['custom_data'] ?? [],
array_filter([
'success_url' => $options['success_url'] ?? null,
'cancel_url' => $options['return_url'] ?? null,
], static fn ($value) => $value !== null && $value !== '')
)
);
$payload = [
'customer_id' => $customerId,
'items' => [
[
'price_id' => $package->paddle_price_id,
'quantity' => 1,
],
],
'custom_data' => $customData,
];
if (! empty($options['discount_id'])) {
$payload['discount_id'] = $options['discount_id'];
}
$response = $this->client->post('/transactions', $payload);
$checkoutUrl = Arr::get($response, 'data.checkout.url')
?? Arr::get($response, 'checkout.url')
?? Arr::get($response, 'data.url')
?? Arr::get($response, 'url');
if (! $checkoutUrl) {
Log::warning('Paddle checkout response missing url', ['response' => $response]);
}
return [
'checkout_url' => $checkoutUrl,
'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'),
];
}
/**
* @param array<string, mixed> $extra
* @return array<string, string>
*/
protected function buildMetadata(Tenant $tenant, Package $package, array $extra = []): array
{
$metadata = [
'tenant_id' => (string) $tenant->id,
'package_id' => (string) $package->id,
];
foreach ($extra as $key => $value) {
if (! is_string($key)) {
continue;
}
if (is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) {
$metadata[$key] = (string) $value;
}
}
return $metadata;
}
}

View File

@@ -1,29 +0,0 @@
<?php
namespace App\Services\Paddle;
class PaddleCustomerPortalService
{
public function __construct(private readonly PaddleClient $client) {}
/**
* @param array{subscription_ids?: array<int, string>} $options
* @return array<string, mixed>
*/
public function createSession(string $customerId, array $options = []): array
{
$payload = [];
if (! empty($options['subscription_ids'])) {
$payload['subscription_ids'] = array_values(
array_filter($options['subscription_ids'], 'is_string')
);
}
if ($payload === []) {
$payload = (object) [];
}
return $this->client->post("/customers/{$customerId}/portal-sessions", $payload);
}
}

View File

@@ -1,80 +0,0 @@
<?php
namespace App\Services\Paddle;
use App\Models\Tenant;
use App\Services\Paddle\Exceptions\PaddleException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
class PaddleCustomerService
{
public function __construct(private readonly PaddleClient $client) {}
public function ensureCustomerId(Tenant $tenant): string
{
if ($tenant->paddle_customer_id) {
return $tenant->paddle_customer_id;
}
$email = $tenant->contact_email ?: ($tenant->user?->email ?? null);
$payload = [
'email' => $email,
'name' => $tenant->name,
];
if (! $payload['email']) {
throw new PaddleException('Tenant email address required to create Paddle customer.');
}
try {
$response = $this->client->post('/customers', $payload);
} catch (PaddleException $exception) {
$existingCustomerId = $this->resolveExistingCustomerId($tenant, $email, $exception);
if ($existingCustomerId) {
$tenant->forceFill(['paddle_customer_id' => $existingCustomerId])->save();
return $existingCustomerId;
}
throw $exception;
}
$customerId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
if (! $customerId) {
Log::error('Paddle customer creation returned no id', ['tenant' => $tenant->id, 'response' => $response]);
throw new PaddleException('Failed to create Paddle customer.');
}
$tenant->forceFill(['paddle_customer_id' => $customerId])->save();
return $customerId;
}
protected function resolveExistingCustomerId(Tenant $tenant, 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,
]);
$customerId = Arr::get($response, 'data.0.id') ?? Arr::get($response, 'data.0.customer_id');
if (! $customerId) {
Log::warning('Paddle customer lookup by email returned no id', [
'tenant' => $tenant->id,
'error_code' => Arr::get($exception->context(), 'error.code'),
]);
return null;
}
return $customerId;
}
}

View File

@@ -1,41 +0,0 @@
<?php
namespace App\Services\Paddle;
use Illuminate\Support\Arr;
class PaddleSubscriptionService
{
public function __construct(private readonly PaddleClient $client) {}
/**
* Retrieve a subscription record directly from Paddle.
*
* @return array<string, mixed>
*/
public function retrieve(string $subscriptionId): array
{
$response = $this->client->get("/subscriptions/{$subscriptionId}");
return is_array($response) ? $response : [];
}
/**
* Convenience helper to extract metadata from the subscription response.
*
* @param array<string, mixed> $subscription
* @return array<string, mixed>
*/
public function metadata(array $subscription): array
{
$customData = Arr::get($subscription, 'data.custom_data');
if (is_array($customData)) {
return $customData;
}
$metadata = Arr::get($subscription, 'data.metadata');
return is_array($metadata) ? $metadata : [];
}
}

View File

@@ -1,188 +0,0 @@
<?php
namespace App\Services\Paddle;
use Illuminate\Support\Arr;
class PaddleTransactionService
{
public function __construct(private readonly PaddleClient $client) {}
/**
* @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
*/
public function listForCustomer(string $customerId, array $query = []): array
{
$payload = array_filter(array_merge([
'customer_id' => $customerId,
'order_by' => 'created_at[desc]',
], $query), static fn ($value) => $value !== null && $value !== '');
$response = $this->client->get('/transactions', $payload);
$transactions = Arr::get($response, 'data', []);
$meta = Arr::get($response, 'meta.pagination', []);
if (! is_array($transactions)) {
$transactions = [];
}
return [
'data' => array_map([$this, 'mapTransaction'], $transactions),
'meta' => $this->mapPagination($meta),
];
}
/**
* @return array<string, mixed>
*/
public function retrieve(string $transactionId): array
{
$response = $this->client->get("/transactions/{$transactionId}");
$transaction = Arr::get($response, 'data');
return is_array($transaction) ? $transaction : (is_array($response) ? $response : []);
}
/**
* @return array<string, mixed>|null
*/
public function findByCheckoutId(string $checkoutId): ?array
{
$response = $this->client->get('/transactions', [
'checkout_id' => $checkoutId,
'order_by' => 'created_at[desc]',
]);
$transactions = Arr::get($response, 'data', []);
if (! is_array($transactions) || $transactions === []) {
return null;
}
$first = $transactions[0] ?? null;
return is_array($first) ? $first : null;
}
/**
* @param array<string, string|int|null> $criteria
* @return array<string, mixed>|null
*/
public function findByCustomData(array $criteria, int $limit = 20): ?array
{
$payload = array_filter([
'order_by' => 'created_at[desc]',
'per_page' => max(1, min($limit, 50)),
], static fn ($value) => $value !== null && $value !== '');
$response = $this->client->get('/transactions', $payload);
$transactions = Arr::get($response, 'data', []);
if (! is_array($transactions) || $transactions === []) {
return null;
}
foreach ($transactions as $transaction) {
if (! is_array($transaction)) {
continue;
}
$customData = Arr::get($transaction, 'custom_data', Arr::get($transaction, 'customData', []));
if (! is_array($customData) || $customData === []) {
continue;
}
$matches = true;
foreach ($criteria as $key => $value) {
if ($value === null || $value === '') {
continue;
}
$candidate = $customData[$key] ?? null;
if ((string) $candidate !== (string) $value) {
$matches = false;
break;
}
}
if ($matches) {
return $transaction;
}
}
return null;
}
/**
* Issue a refund for a Paddle transaction.
*
* @param array{reason?: string|null} $options
* @return array<string, mixed>
*/
public function refund(string $transactionId, array $options = []): array
{
$payload = array_filter([
'reason' => $options['reason'] ?? null,
], static fn ($value) => $value !== null && $value !== '');
return $this->client->post("/transactions/{$transactionId}/refunds", $payload);
}
/**
* @param array<string, mixed> $transaction
* @return array<string, mixed>
*/
protected function mapTransaction(array $transaction): array
{
$totals = Arr::get($transaction, 'totals', []);
return [
'id' => $transaction['id'] ?? null,
'status' => $transaction['status'] ?? null,
'amount' => $this->resolveAmount($transaction, $totals),
'currency' => $transaction['currency_code'] ?? Arr::get($transaction, 'currency') ?? 'EUR',
'origin' => $transaction['origin'] ?? null,
'checkout_id' => $transaction['checkout_id'] ?? Arr::get($transaction, 'details.checkout_id'),
'created_at' => $transaction['created_at'] ?? null,
'updated_at' => $transaction['updated_at'] ?? null,
'receipt_url' => Arr::get($transaction, 'invoice_url') ?? Arr::get($transaction, 'receipt_url'),
'tax' => Arr::get($totals, 'tax_total') ?? null,
'grand_total' => Arr::get($totals, 'grand_total') ?? null,
];
}
/**
* @param array<string, mixed> $transaction
* @param array<string, mixed>|null $totals
*/
protected function resolveAmount(array $transaction, $totals): ?float
{
$amount = Arr::get($totals ?? [], 'subtotal') ?? Arr::get($totals ?? [], 'grand_total');
if ($amount !== null) {
return (float) $amount;
}
$raw = $transaction['amount'] ?? null;
if ($raw === null) {
return null;
}
return (float) $raw;
}
/**
* @param array<string, mixed> $pagination
* @return array<string, mixed>
*/
protected function mapPagination(array $pagination): array
{
return [
'next' => $pagination['next'] ?? null,
'previous' => $pagination['previous'] ?? null,
'has_more' => (bool) ($pagination['has_more'] ?? false),
];
}
}

View File

@@ -46,8 +46,8 @@ trait PresentsPackages
'type' => $package->type, 'type' => $package->type,
'included_package_slug' => $package->included_package_slug, 'included_package_slug' => $package->included_package_slug,
'price' => $package->price, 'price' => $package->price,
'paddle_product_id' => $package->paddle_product_id, 'lemonsqueezy_product_id' => $package->lemonsqueezy_product_id,
'paddle_price_id' => $package->paddle_price_id, 'lemonsqueezy_variant_id' => $package->lemonsqueezy_variant_id,
'description' => $description, 'description' => $description,
'description_breakdown' => $table, 'description_breakdown' => $table,
'gallery_duration_label' => $galleryDuration, 'gallery_duration_label' => $galleryDuration,

View File

@@ -110,8 +110,8 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']); $middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->validateCsrfTokens(except: [ $middleware->validateCsrfTokens(except: [
'paddle/webhook', 'lemonsqueezy/webhook',
'paddle/webhook*', 'lemonsqueezy/webhook*',
]); ]);
$middleware->web(append: [ $middleware->web(append: [

View File

@@ -5,70 +5,70 @@ return [
'reminder_days' => 7, 'reminder_days' => 7,
'expiry_reminder_days' => 14, 'expiry_reminder_days' => 14,
// Map voucher tiers to Paddle price IDs (create matching prices in Paddle Billing). // Map voucher tiers to Lemon Squeezy variant IDs (create matching variants in Lemon Squeezy).
'tiers' => [ 'tiers' => [
[ [
'key' => 'gift-starter', 'key' => 'gift-starter',
'label' => 'Geschenk Starter', 'label' => 'Geschenk Starter',
'amount' => 29.00, 'amount' => 29.00,
'currency' => 'EUR', 'currency' => 'EUR',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STARTER', 'pri_01kbwccfe1mpwh7hh60eygemx6'), 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STARTER'),
], ],
[ [
'key' => 'gift-starter-usd', 'key' => 'gift-starter-usd',
'label' => 'Gift Starter (USD)', 'label' => 'Gift Starter (USD)',
'amount' => 32.00, 'amount' => 32.00,
'currency' => 'USD', 'currency' => 'USD',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STARTER_USD'), 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STARTER_USD'),
], ],
[ [
'key' => 'gift-starter-gbp', 'key' => 'gift-starter-gbp',
'label' => 'Gift Starter (GBP)', 'label' => 'Gift Starter (GBP)',
'amount' => 25.00, 'amount' => 25.00,
'currency' => 'GBP', 'currency' => 'GBP',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STARTER_GBP'), 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STARTER_GBP'),
], ],
[ [
'key' => 'gift-standard', 'key' => 'gift-standard',
'label' => 'Geschenk Classic', 'label' => 'Geschenk Classic',
'amount' => 59.00, 'amount' => 59.00,
'currency' => 'EUR', 'currency' => 'EUR',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD', 'pri_01kbwccfvzrf4z2f1r62vns7gh'), 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STANDARD'),
], ],
[ [
'key' => 'gift-standard-usd', 'key' => 'gift-standard-usd',
'label' => 'Gift Classic (USD)', 'label' => 'Gift Classic (USD)',
'amount' => 65.00, 'amount' => 65.00,
'currency' => 'USD', 'currency' => 'USD',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_USD'), 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STANDARD_USD'),
], ],
[ [
'key' => 'gift-standard-gbp', 'key' => 'gift-standard-gbp',
'label' => 'Gift Classic (GBP)', 'label' => 'Gift Classic (GBP)',
'amount' => 55.00, 'amount' => 55.00,
'currency' => 'GBP', 'currency' => 'GBP',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_GBP'), 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STANDARD_GBP'),
], ],
[ [
'key' => 'gift-premium', 'key' => 'gift-premium',
'label' => 'Geschenk Premium', 'label' => 'Geschenk Premium',
'amount' => 129.00, 'amount' => 129.00,
'currency' => 'EUR', 'currency' => 'EUR',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM', 'pri_01kbwccg8vjc5cwz0kftfvf9wm'), 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_PREMIUM'),
], ],
[ [
'key' => 'gift-premium-usd', 'key' => 'gift-premium-usd',
'label' => 'Gift Premium (USD)', 'label' => 'Gift Premium (USD)',
'amount' => 139.00, 'amount' => 139.00,
'currency' => 'USD', 'currency' => 'USD',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_USD'), 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_USD'),
], ],
[ [
'key' => 'gift-premium-gbp', 'key' => 'gift-premium-gbp',
'label' => 'Gift Premium (GBP)', 'label' => 'Gift Premium (GBP)',
'amount' => 119.00, 'amount' => 119.00,
'currency' => 'GBP', 'currency' => 'GBP',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_GBP'), 'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_GBP'),
], ],
], ],

12
config/lemonsqueezy.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
$testMode = filter_var(env('LEMONSQUEEZY_TEST_MODE', false), FILTER_VALIDATE_BOOLEAN);
return [
'api_key' => env('LEMONSQUEEZY_API_KEY'),
'store_id' => env('LEMONSQUEEZY_STORE_ID', '284860'),
'test_mode' => $testMode,
'webhook_secret' => env('LEMONSQUEEZY_WEBHOOK_SECRET'),
'webhook_events' => array_filter(array_map('trim', explode(',', (string) env('LEMONSQUEEZY_WEBHOOK_EVENTS', 'order_created,order_updated,order_payment_failed,order_refunded,subscription_created,subscription_updated,subscription_cancelled,subscription_expired,subscription_paused')))),
'base_url' => env('LEMONSQUEEZY_BASE_URL', 'https://api.lemonsqueezy.com/v1'),
];

View File

@@ -80,14 +80,14 @@ return [
'days' => env('LOG_DAILY_DAYS', 14), 'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true, 'replace_placeholders' => true,
], ],
'paddle-sync' => [ 'lemonsqueezy-sync' => [
'driver' => 'stack', 'driver' => 'stack',
'channels' => explode(',', env('PADDLE_SYNC_LOG_STACK', 'paddle-sync-file')), 'channels' => explode(',', env('LEMONSQUEEZY_SYNC_LOG_STACK', 'lemonsqueezy-sync-file')),
'ignore_exceptions' => false, 'ignore_exceptions' => false,
], ],
'paddle-sync-file' => [ 'lemonsqueezy-sync-file' => [
'driver' => 'daily', 'driver' => 'daily',
'path' => storage_path('logs/paddle-sync.log'), 'path' => storage_path('logs/lemonsqueezy-sync.log'),
'level' => env('LOG_LEVEL', 'info'), 'level' => env('LOG_LEVEL', 'info'),
'days' => env('LOG_DAILY_DAYS', 14), 'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true, 'replace_placeholders' => true,

View File

@@ -1,31 +1,31 @@
<?php <?php
return [ return [
// Keyed add-ons with display and Paddle mapping. Amounts are base increments; multiply by quantity. // Keyed add-ons with display and Lemon Squeezy mapping. Amounts are base increments; multiply by quantity.
'extra_photos_small' => [ 'extra_photos_small' => [
'label' => 'Extra photos (500)', 'label' => 'Extra photos (500)',
'price_id' => env('PADDLE_ADDON_EXTRA_PHOTOS_SMALL_PRICE_ID'), 'variant_id' => env('LEMONSQUEEZY_ADDON_EXTRA_PHOTOS_SMALL_VARIANT_ID'),
'increments' => [ 'increments' => [
'extra_photos' => 500, 'extra_photos' => 500,
], ],
], ],
'extra_photos_large' => [ 'extra_photos_large' => [
'label' => 'Extra photos (2,000)', 'label' => 'Extra photos (2,000)',
'price_id' => env('PADDLE_ADDON_EXTRA_PHOTOS_LARGE_PRICE_ID'), 'variant_id' => env('LEMONSQUEEZY_ADDON_EXTRA_PHOTOS_LARGE_VARIANT_ID'),
'increments' => [ 'increments' => [
'extra_photos' => 2000, 'extra_photos' => 2000,
], ],
], ],
'extra_guests' => [ 'extra_guests' => [
'label' => 'Extra guests (100)', 'label' => 'Extra guests (100)',
'price_id' => env('PADDLE_ADDON_EXTRA_GUESTS_PRICE_ID'), 'variant_id' => env('LEMONSQUEEZY_ADDON_EXTRA_GUESTS_VARIANT_ID'),
'increments' => [ 'increments' => [
'extra_guests' => 100, 'extra_guests' => 100,
], ],
], ],
'extend_gallery_30d' => [ 'extend_gallery_30d' => [
'label' => 'Gallery extension (30 days)', 'label' => 'Gallery extension (30 days)',
'price_id' => env('PADDLE_ADDON_EXTEND_GALLERY_30D_PRICE_ID'), 'variant_id' => env('LEMONSQUEEZY_ADDON_EXTEND_GALLERY_30D_VARIANT_ID'),
'increments' => [ 'increments' => [
'extra_gallery_days' => 30, 'extra_gallery_days' => 30,
], ],

View File

@@ -1,48 +0,0 @@
<?php
$sandbox = filter_var(env('PADDLE_SANDBOX', false), FILTER_VALIDATE_BOOLEAN);
$environment = env('PADDLE_ENVIRONMENT', $sandbox ? 'sandbox' : 'production');
$apiKey = env('PADDLE_API_KEY') ?: env('PADDLE_SANDBOX_API_KEY');
$clientToken = $sandbox
? (env('PADDLE_SANDBOX_CLIENT_TOKEN') ?: env('PADDLE_SANDBOX_CLIENT_ID') ?: env('PADDLE_CLIENT_TOKEN') ?: env('PADDLE_CLIENT_ID'))
: (env('PADDLE_CLIENT_TOKEN') ?: env('PADDLE_CLIENT_ID'));
$webhookSecret = env('PADDLE_WEBHOOK_SECRET') ?: ($sandbox ? env('PADDLE_SANDBOX_WEBHOOK_SECRET') : null);
$publicKey = env('PADDLE_PUBLIC_KEY') ?: ($sandbox ? env('PADDLE_SANDBOX_PUBLIC_KEY') : null);
$baseUrl = env('PADDLE_BASE_URL');
if (! $baseUrl) {
$baseUrl = $sandbox ? 'https://sandbox-api.paddle.com' : 'https://api.paddle.com';
}
$consoleUrl = env('PADDLE_CONSOLE_URL');
if (! $consoleUrl) {
$consoleUrl = $sandbox ? 'https://sandbox-dashboard.paddle.com' : 'https://dashboard.paddle.com';
}
return [
'api_key' => $apiKey,
'client_token' => $clientToken,
'environment' => $environment,
'base_url' => $baseUrl,
'console_url' => $consoleUrl,
'webhook_secret' => $webhookSecret,
'public_key' => $publicKey,
'webhook_events' => [
'transaction.created',
'transaction.processing',
'transaction.completed',
'transaction.failed',
'transaction.cancelled',
'subscription.created',
'subscription.updated',
'subscription.paused',
'subscription.resumed',
'subscription.cancelled',
'subscription.past_due',
],
];

View File

@@ -45,11 +45,11 @@ return [
'sandbox' => env('PAYPAL_SANDBOX', true), 'sandbox' => env('PAYPAL_SANDBOX', true),
], ],
'paddle' => [ 'lemonsqueezy' => [
'api_key' => env('PADDLE_API_KEY'), 'api_key' => env('LEMONSQUEEZY_API_KEY'),
'client_id' => env('PADDLE_CLIENT_ID'), 'store_id' => env('LEMONSQUEEZY_STORE_ID'),
'sandbox' => env('PADDLE_SANDBOX', false), 'webhook_secret' => env('LEMONSQUEEZY_WEBHOOK_SECRET'),
'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'), 'test_mode' => env('LEMONSQUEEZY_TEST_MODE', false),
], ],
'google' => [ 'google' => [

View File

@@ -60,7 +60,7 @@ return [
'resources' => [ 'resources' => [
'tenants' => [ 'tenants' => [
'model' => Tenant::class, 'model' => Tenant::class,
'search' => ['name', 'slug', 'contact_email', 'paddle_customer_id'], 'search' => ['name', 'slug', 'contact_email', 'lemonsqueezy_customer_id'],
'with' => ['user', 'activeResellerPackage'], 'with' => ['user', 'activeResellerPackage'],
'abilities' => [ 'abilities' => [
'read' => ['support:read'], 'read' => ['support:read'],

View File

@@ -44,8 +44,8 @@ class CouponFactory extends Factory
'metadata' => ['note' => 'factory'], 'metadata' => ['note' => 'factory'],
'starts_at' => now()->subDay(), 'starts_at' => now()->subDay(),
'ends_at' => now()->addMonth(), 'ends_at' => now()->addMonth(),
'paddle_discount_id' => 'dsc_'.Str::upper(Str::random(10)), 'lemonsqueezy_discount_id' => 'dsc_'.Str::upper(Str::random(10)),
'paddle_mode' => 'standard', 'lemonsqueezy_mode' => 'standard',
]; ];
} }
} }

View File

@@ -23,7 +23,7 @@ class IntegrationWebhookEventFactory extends Factory
$processedAt = (clone $receivedAt)->modify('+2 minutes'); $processedAt = (clone $receivedAt)->modify('+2 minutes');
return [ return [
'provider' => $this->faker->randomElement(['paddle', 'revenuecat']), 'provider' => $this->faker->randomElement(['lemonsqueezy', 'revenuecat']),
'event_id' => $this->faker->uuid(), 'event_id' => $this->faker->uuid(),
'event_type' => $this->faker->word(), 'event_type' => $this->faker->word(),
'status' => IntegrationWebhookEvent::STATUS_PROCESSED, 'status' => IntegrationWebhookEvent::STATUS_PROCESSED,

View File

@@ -30,9 +30,9 @@ class PackageFactory extends Factory
'advanced_analytics' => $this->faker->boolean(), 'advanced_analytics' => $this->faker->boolean(),
]), ]),
'type' => $this->faker->randomElement(['endcustomer', 'reseller']), 'type' => $this->faker->randomElement(['endcustomer', 'reseller']),
'paddle_sync_status' => null, 'lemonsqueezy_sync_status' => null,
'paddle_synced_at' => null, 'lemonsqueezy_synced_at' => null,
'paddle_snapshot' => null, 'lemonsqueezy_snapshot' => null,
]; ];
} }

View File

@@ -34,8 +34,8 @@ return new class extends Migration
$table->string('stripe_payment_intent_id')->nullable(); $table->string('stripe_payment_intent_id')->nullable();
$table->string('stripe_customer_id')->nullable(); $table->string('stripe_customer_id')->nullable();
$table->string('stripe_subscription_id')->nullable(); $table->string('stripe_subscription_id')->nullable();
$table->string('paddle_checkout_id')->nullable(); $table->string('lemonsqueezy_checkout_id')->nullable();
$table->string('paddle_transaction_id')->nullable(); $table->string('lemonsqueezy_order_id')->nullable();
$table->json('provider_metadata')->nullable(); $table->json('provider_metadata')->nullable();
$table->string('locale', 5)->nullable(); $table->string('locale', 5)->nullable();
@@ -47,8 +47,8 @@ return new class extends Migration
$table->softDeletes(); $table->softDeletes();
$table->unique('stripe_payment_intent_id'); $table->unique('stripe_payment_intent_id');
$table->unique('paddle_checkout_id'); $table->unique('lemonsqueezy_checkout_id');
$table->unique('paddle_transaction_id'); $table->unique('lemonsqueezy_order_id');
$table->index(['provider', 'status']); $table->index(['provider', 'status']);
$table->index('expires_at'); $table->index('expires_at');
}); });

View File

@@ -11,26 +11,26 @@ return new class extends Migration
*/ */
public function up(): void public function up(): void
{ {
if (! Schema::hasColumn('packages', 'paddle_product_id')) { if (! Schema::hasColumn('packages', 'lemonsqueezy_product_id')) {
Schema::table('packages', function (Blueprint $table) { Schema::table('packages', function (Blueprint $table) {
$table->string('paddle_product_id')->nullable()->after('price'); $table->string('lemonsqueezy_product_id')->nullable()->after('price');
$table->string('paddle_price_id')->nullable()->after('paddle_product_id'); $table->string('lemonsqueezy_variant_id')->nullable()->after('lemonsqueezy_product_id');
$table->index('paddle_product_id'); $table->index('lemonsqueezy_product_id');
$table->index('paddle_price_id'); $table->index('lemonsqueezy_variant_id');
}); });
} }
if (! Schema::hasColumn('tenants', 'paddle_customer_id')) { if (! Schema::hasColumn('tenants', 'lemonsqueezy_customer_id')) {
Schema::table('tenants', function (Blueprint $table) { Schema::table('tenants', function (Blueprint $table) {
$table->string('paddle_customer_id')->nullable()->after('subscription_status'); $table->string('lemonsqueezy_customer_id')->nullable()->after('subscription_status');
$table->index('paddle_customer_id'); $table->index('lemonsqueezy_customer_id');
}); });
} }
if (! Schema::hasColumn('tenant_packages', 'paddle_subscription_id')) { if (! Schema::hasColumn('tenant_packages', 'lemonsqueezy_subscription_id')) {
Schema::table('tenant_packages', function (Blueprint $table) { Schema::table('tenant_packages', function (Blueprint $table) {
$table->string('paddle_subscription_id')->nullable()->after('package_id'); $table->string('lemonsqueezy_subscription_id')->nullable()->after('package_id');
$table->index('paddle_subscription_id'); $table->index('lemonsqueezy_subscription_id');
}); });
} }
@@ -47,31 +47,31 @@ return new class extends Migration
*/ */
public function down(): void public function down(): void
{ {
if (Schema::hasColumn('packages', 'paddle_price_id')) { if (Schema::hasColumn('packages', 'lemonsqueezy_variant_id')) {
Schema::table('packages', function (Blueprint $table) { Schema::table('packages', function (Blueprint $table) {
$table->dropIndex('packages_paddle_price_id_index'); $table->dropIndex('packages_lemonsqueezy_variant_id_index');
$table->dropColumn('paddle_price_id'); $table->dropColumn('lemonsqueezy_variant_id');
}); });
} }
if (Schema::hasColumn('packages', 'paddle_product_id')) { if (Schema::hasColumn('packages', 'lemonsqueezy_product_id')) {
Schema::table('packages', function (Blueprint $table) { Schema::table('packages', function (Blueprint $table) {
$table->dropIndex('packages_paddle_product_id_index'); $table->dropIndex('packages_lemonsqueezy_product_id_index');
$table->dropColumn('paddle_product_id'); $table->dropColumn('lemonsqueezy_product_id');
}); });
} }
if (Schema::hasColumn('tenants', 'paddle_customer_id')) { if (Schema::hasColumn('tenants', 'lemonsqueezy_customer_id')) {
Schema::table('tenants', function (Blueprint $table) { Schema::table('tenants', function (Blueprint $table) {
$table->dropIndex('tenants_paddle_customer_id_index'); $table->dropIndex('tenants_lemonsqueezy_customer_id_index');
$table->dropColumn('paddle_customer_id'); $table->dropColumn('lemonsqueezy_customer_id');
}); });
} }
if (Schema::hasColumn('tenant_packages', 'paddle_subscription_id')) { if (Schema::hasColumn('tenant_packages', 'lemonsqueezy_subscription_id')) {
Schema::table('tenant_packages', function (Blueprint $table) { Schema::table('tenant_packages', function (Blueprint $table) {
$table->dropIndex('tenant_packages_paddle_subscription_id_index'); $table->dropIndex('tenant_packages_lemonsqueezy_subscription_id_index');
$table->dropColumn('paddle_subscription_id'); $table->dropColumn('lemonsqueezy_subscription_id');
}); });
} }

View File

@@ -12,15 +12,15 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::table('packages', function (Blueprint $table) { Schema::table('packages', function (Blueprint $table) {
$table->string('paddle_sync_status', 50) $table->string('lemonsqueezy_sync_status', 50)
->nullable() ->nullable()
->after('paddle_price_id'); ->after('lemonsqueezy_variant_id');
$table->timestamp('paddle_synced_at') $table->timestamp('lemonsqueezy_synced_at')
->nullable() ->nullable()
->after('paddle_sync_status'); ->after('lemonsqueezy_sync_status');
$table->json('paddle_snapshot') $table->json('lemonsqueezy_snapshot')
->nullable() ->nullable()
->after('paddle_synced_at'); ->after('lemonsqueezy_synced_at');
}); });
} }
@@ -30,7 +30,7 @@ return new class extends Migration
public function down(): void public function down(): void
{ {
Schema::table('packages', function (Blueprint $table) { Schema::table('packages', function (Blueprint $table) {
$table->dropColumn(['paddle_sync_status', 'paddle_synced_at', 'paddle_snapshot']); $table->dropColumn(['lemonsqueezy_sync_status', 'lemonsqueezy_synced_at', 'lemonsqueezy_snapshot']);
}); });
} }
}; };

View File

@@ -36,10 +36,10 @@ return new class extends Migration
$table->timestamp('starts_at')->nullable(); $table->timestamp('starts_at')->nullable();
$table->timestamp('ends_at')->nullable(); $table->timestamp('ends_at')->nullable();
$table->string('paddle_discount_id')->nullable()->unique(); $table->string('lemonsqueezy_discount_id')->nullable()->unique();
$table->string('paddle_mode', 40)->default('standard'); $table->string('lemonsqueezy_mode', 40)->default('standard');
$table->json('paddle_snapshot')->nullable(); $table->json('lemonsqueezy_snapshot')->nullable();
$table->timestamp('paddle_last_synced_at')->nullable(); $table->timestamp('lemonsqueezy_last_synced_at')->nullable();
$table->foreignIdFor(\App\Models\User::class, 'created_by')->nullable()->constrained('users')->nullOnDelete(); $table->foreignIdFor(\App\Models\User::class, 'created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignIdFor(\App\Models\User::class, 'updated_by')->nullable()->constrained('users')->nullOnDelete(); $table->foreignIdFor(\App\Models\User::class, 'updated_by')->nullable()->constrained('users')->nullOnDelete();

Some files were not shown because too many files have changed in this diff Show More