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_SANDBOX=true
# Paddle Billing
PADDLE_SANDBOX=true
PADDLE_API_KEY=
PADDLE_CLIENT_ID=
PADDLE_WEBHOOK_SECRET=
PADDLE_PUBLIC_KEY=
PADDLE_BASE_URL=
PADDLE_CONSOLE_URL=
# Lemon Squeezy Billing
LEMONSQUEEZY_STORE_ID=284860
LEMONSQUEEZY_API_KEY=
LEMONSQUEEZY_WEBHOOK_SECRET=
LEMONSQUEEZY_WEBHOOK_EVENTS=
LEMONSQUEEZY_TEST_MODE=false
LEMONSQUEEZY_BASE_URL=https://api.lemonsqueezy.com/v1
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_STATEFUL_DOMAINS=localhost,localhost:3000

View File

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

View File

@@ -503083,6 +503083,20 @@
"val": 14,
"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": {
"isVar": true,
"key": "$pill",
@@ -505293,6 +505307,20 @@
"val": "hsl(53, 92.0%, 50.0%)",
"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": {
"isVar": true,
"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.
- 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.
- Payment Systems: Paddle (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use).
- 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: 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.
## Repo Structure (high-level)
@@ -61,7 +61,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
#### Billing & Packages
- package:check-status — check event package status.
- 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.
- 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).
- 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.
- Payment Integration: Paddle webhooks, RevenueCat mobile subscriptions.
- Payment Integration: Lemon Squeezy webhooks, RevenueCat mobile subscriptions.
## PWA Architecture
- 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;
use App\Jobs\PullPackageFromPaddle;
use App\Jobs\SyncPackageToPaddle;
use App\Jobs\PullPackageFromLemonSqueezy;
use App\Jobs\SyncPackageToLemonSqueezy;
use App\Models\Package;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
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}
{--dry-run : Generate payload snapshots without calling Paddle}
{--pull : Fetch remote Paddle state instead of pushing local changes}
{--allow-unmapped : Allow sync when packages are missing Paddle product/price IDs}
{--dry-run : Generate payload snapshots without calling Lemon Squeezy}
{--pull : Fetch remote Lemon Squeezy state instead of pushing local changes}
{--allow-unmapped : Allow sync when packages are missing Lemon Squeezy product/variant IDs}
{--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
{
@@ -52,7 +52,7 @@ class PaddleSyncPackages extends Command
});
$this->info(sprintf(
'Queued %d package %s for Paddle %s.',
'Queued %d package %s for Lemon Squeezy %s.',
$packages->count(),
Str::plural('entry', $packages->count()),
$pull ? 'pull' : 'sync'
@@ -97,22 +97,22 @@ class PaddleSyncPackages extends Command
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()) {
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(
['ID', 'Slug', 'Missing'],
$unmapped->map(function (Package $package): array {
$missing = [];
if (blank($package->paddle_product_id)) {
if (blank($package->lemonsqueezy_product_id)) {
$missing[] = 'product_id';
}
if (blank($package->paddle_price_id)) {
$missing[] = 'price_id';
if (blank($package->lemonsqueezy_variant_id)) {
$missing[] = 'variant_id';
}
return [
@@ -133,26 +133,26 @@ class PaddleSyncPackages extends Command
];
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));
return;
}
SyncPackageToPaddle::dispatchSync($package->id, $context);
SyncPackageToLemonSqueezy::dispatchSync($package->id, $context);
$this->line(sprintf('> synced package #%d (%s)', $package->id, $package->slug));
}
protected function dispatchPullJob(Package $package, bool $queue): void
{
if ($queue) {
PullPackageFromPaddle::dispatch($package->id);
PullPackageFromLemonSqueezy::dispatch($package->id);
$this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug));
return;
}
PullPackageFromPaddle::dispatchSync($package->id);
PullPackageFromLemonSqueezy::dispatchSync($package->id);
$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
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\Tenant;
use Filament\Forms\Components\TextInput;
@@ -15,7 +15,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class TenantPaddleHealthTable
class TenantLemonSqueezyHealthTable
{
private const FAILED_SYNC_STATUSES = ['failed', 'pull-failed'];
@@ -35,8 +35,8 @@ class TenantPaddleHealthTable
->label(__('admin.tenants.fields.contact_email'))
->searchable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_customer_id')
->label('Paddle customer')
TextColumn::make('lemonsqueezy_customer_id')
->label('Lemon Squeezy customer')
->toggleable(isToggledHiddenByDefault: true)
->copyable()
->formatStateUsing(fn (?string $state) => $state ?: '—'),
@@ -56,27 +56,27 @@ class TenantPaddleHealthTable
->badge()
->color(fn (string $state) => $state === '—' ? 'gray' : 'success')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_subscription_id')
->label('Paddle subscription')
TextColumn::make('lemonsqueezy_subscription_id')
->label('Lemon Squeezy subscription')
->toggleable(isToggledHiddenByDefault: true)
->copyable()
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->paddle_subscription_id)
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->lemonsqueezy_subscription_id)
->formatStateUsing(fn (?string $state) => $state ?: '—'),
IconColumn::make('missing_paddle_subscription')
->label('Missing Paddle subscription')
IconColumn::make('missing_lemonsqueezy_subscription')
->label('Missing Lemon Squeezy subscription')
->boolean()
->getStateUsing(fn (Tenant $record) => self::missingPaddleSubscription($record)),
->getStateUsing(fn (Tenant $record) => self::missingLemonSqueezySubscription($record)),
IconColumn::make('status_mismatch')
->label('Status mismatch')
->boolean()
->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)),
TextColumn::make('paddle_customer_duplicates')
->label('Paddle duplicates')
TextColumn::make('lemonsqueezy_customer_duplicates')
->label('Lemon Squeezy duplicates')
->sortable()
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn (?int $state) => $state && $state > 1 ? (string) $state : '—'),
TextColumn::make('paddle_sync_status')
->label('Paddle sync')
TextColumn::make('lemonsqueezy_sync_status')
->label('Lemon Squeezy sync')
->badge()
->color(fn (?string $state) => match ($state) {
'synced' => 'success',
@@ -87,101 +87,101 @@ class TenantPaddleHealthTable
default => 'gray',
})
->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),
TextColumn::make('paddle_synced_at')
->label('Paddle synced')
TextColumn::make('lemonsqueezy_synced_at')
->label('Lemon Squeezy synced')
->badge()
->color(fn ($state) => self::syncAgeColor($state))
->formatStateUsing(fn ($state) => $state?->diffForHumans() ?? '—')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_synced_at),
TextColumn::make('last_paddle_transaction_at')
->label('Last Paddle tx')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->lemonsqueezy_synced_at),
TextColumn::make('last_lemonsqueezy_transaction_at')
->label('Last Lemon Squeezy tx')
->badge()
->color(fn (?Carbon $state) => self::transactionAgeColor($state))
->getStateUsing(fn (Tenant $record) => $record->last_paddle_transaction_at
? Carbon::parse($record->last_paddle_transaction_at)
->getStateUsing(fn (Tenant $record) => $record->last_lemonsqueezy_transaction_at
? Carbon::parse($record->last_lemonsqueezy_transaction_at)
: null)
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_transaction_count_window')
->label('Paddle tx (30d)')
TextColumn::make('lemonsqueezy_transaction_count_window')
->label('Lemon Squeezy tx (30d)')
->default('0')
->sortable()
->toggleable(),
TextColumn::make('paddle_transaction_total_window')
->label('Paddle total (30d)')
TextColumn::make('lemonsqueezy_transaction_total_window')
->label('Lemon Squeezy total (30d)')
->default(0)
->money('EUR')
->sortable()
->toggleable(),
TextColumn::make('paddle_refund_count_window')
TextColumn::make('lemonsqueezy_refund_count_window')
->label('Refunds (30d)')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_refund_total_window')
TextColumn::make('lemonsqueezy_refund_total_window')
->label('Refund total (30d)')
->default(0)
->money('EUR')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_checkout_requires_action_count')
TextColumn::make('lemonsqueezy_checkout_requires_action_count')
->label('Checkout action required')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_checkout_processing_count')
TextColumn::make('lemonsqueezy_checkout_processing_count')
->label('Checkout processing')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_checkout_expired_count')
TextColumn::make('lemonsqueezy_checkout_expired_count')
->label('Checkout expired')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_transaction_count')
->label('Paddle tx (all)')
TextColumn::make('lemonsqueezy_transaction_count')
->label('Lemon Squeezy tx (all)')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_transaction_total')
->label('Paddle total (all)')
TextColumn::make('lemonsqueezy_transaction_total')
->label('Lemon Squeezy total (all)')
->default(0)
->money('EUR')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Filter::make('missing_paddle_customer')
->label('Missing Paddle customer')
->indicator('Missing Paddle customer')
->query(fn (Builder $query) => $query->whereNull('paddle_customer_id')),
Filter::make('missing_paddle_subscription')
->label('Missing Paddle subscription')
->indicator('Missing Paddle subscription')
Filter::make('missing_lemonsqueezy_customer')
->label('Missing Lemon Squeezy customer')
->indicator('Missing Lemon Squeezy customer')
->query(fn (Builder $query) => $query->whereNull('lemonsqueezy_customer_id')),
Filter::make('missing_lemonsqueezy_subscription')
->label('Missing Lemon Squeezy subscription')
->indicator('Missing Lemon Squeezy subscription')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', fn (Builder $query) => $query
->where('active', true)
->whereNull('paddle_subscription_id'))),
Filter::make('duplicate_paddle_customer')
->label('Duplicate Paddle customer')
->indicator('Duplicate Paddle customer')
->whereNull('lemonsqueezy_subscription_id'))),
Filter::make('duplicate_lemonsqueezy_customer')
->label('Duplicate Lemon Squeezy customer')
->indicator('Duplicate Lemon Squeezy customer')
->query(fn (Builder $query) => $query
->whereNotNull('paddle_customer_id')
->whereIn('paddle_customer_id', function ($subquery) {
$subquery->select('paddle_customer_id')
->whereNotNull('lemonsqueezy_customer_id')
->whereIn('lemonsqueezy_customer_id', function ($subquery) {
$subquery->select('lemonsqueezy_customer_id')
->from('tenants')
->whereNotNull('paddle_customer_id')
->groupBy('paddle_customer_id')
->whereNotNull('lemonsqueezy_customer_id')
->groupBy('lemonsqueezy_customer_id')
->havingRaw('count(*) > 1');
})),
Filter::make('status_mismatch')
@@ -205,39 +205,39 @@ class TenantPaddleHealthTable
->where('is_suspended', false)
->whereNull('pending_deletion_at')
->whereNull('anonymized_at')),
Filter::make('paddle_sync_failed')
->label('Paddle sync failed')
->indicator('Paddle sync failed')
Filter::make('lemonsqueezy_sync_failed')
->label('Lemon Squeezy sync failed')
->indicator('Lemon Squeezy sync failed')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereIn('paddle_sync_status', self::FAILED_SYNC_STATUSES))),
Filter::make('paddle_sync_stale')
->label('Paddle sync stale')
->indicator('Paddle sync stale')
->whereIn('lemonsqueezy_sync_status', self::FAILED_SYNC_STATUSES))),
Filter::make('lemonsqueezy_sync_stale')
->label('Lemon Squeezy sync stale')
->indicator('Lemon Squeezy sync stale')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereNotNull('paddle_synced_at')
->where('paddle_synced_at', '<', now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS)))),
Filter::make('paddle_sync_missing')
->label('Missing Paddle sync timestamp')
->indicator('Missing Paddle sync timestamp')
->whereNotNull('lemonsqueezy_synced_at')
->where('lemonsqueezy_synced_at', '<', now()->subDays(TenantLemonSqueezyHealthResource::STALE_SYNC_DAYS)))),
Filter::make('lemonsqueezy_sync_missing')
->label('Missing Lemon Squeezy sync timestamp')
->indicator('Missing Lemon Squeezy sync timestamp')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereNull('paddle_synced_at'))),
Filter::make('paddle_transaction_stale')
->label('Stale Paddle transactions')
->indicator('Stale Paddle transactions')
->whereNull('lemonsqueezy_synced_at'))),
Filter::make('lemonsqueezy_transaction_stale')
->label('Stale Lemon Squeezy transactions')
->indicator('Stale Lemon Squeezy transactions')
->query(function (Builder $query): Builder {
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
$cutoff = now()->subDays(TenantLemonSqueezyHealthResource::TRANSACTION_WINDOW_DAYS);
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
->where('provider', 'paddle')
->where('provider', 'lemonsqueezy')
->where('purchased_at', '>=', $cutoff));
}),
Filter::make('checkout_attention')
->label('Checkout attention')
->indicator('Checkout attention')
->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) {
$query->where('provider', 'paddle')
$query->where('provider', 'lemonsqueezy')
->where(function (Builder $query) {
$query->whereIn('status', [
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
@@ -274,10 +274,10 @@ class TenantPaddleHealthTable
return $query;
}
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
$cutoff = now()->subDays(TenantLemonSqueezyHealthResource::TRANSACTION_WINDOW_DAYS);
return $query->whereHas('purchases', fn (Builder $query) => $query
->where('provider', 'paddle')
->where('provider', 'lemonsqueezy')
->where('refunded', true)
->where('purchased_at', '>=', $cutoff), '>=', $min);
}),
@@ -314,11 +314,11 @@ class TenantPaddleHealthTable
return false;
}
private static function missingPaddleSubscription(Tenant $record): bool
private static function missingLemonSqueezySubscription(Tenant $record): bool
{
$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
@@ -344,7 +344,7 @@ class TenantPaddleHealthTable
return 'gray';
}
if ($state->lt(now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS))) {
if ($state->lt(now()->subDays(TenantLemonSqueezyHealthResource::STALE_SYNC_DAYS))) {
return 'danger';
}
@@ -357,7 +357,7 @@ class TenantPaddleHealthTable
return 'gray';
}
if ($state->lt(now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS))) {
if ($state->lt(now()->subDays(TenantLemonSqueezyHealthResource::TRANSACTION_WINDOW_DAYS))) {
return 'danger';
}

View File

@@ -1,10 +1,10 @@
<?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\Resources\TenantPaddleHealths\Pages\ListTenantPaddleHealths;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables\TenantPaddleHealthTable;
use App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\Pages\ListTenantLemonSqueezyHealths;
use App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\Tables\TenantLemonSqueezyHealthTable;
use App\Models\CheckoutSession;
use App\Models\Tenant;
use BackedEnum;
@@ -13,7 +13,7 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class TenantPaddleHealthResource extends Resource
class TenantLemonSqueezyHealthResource extends Resource
{
public const STALE_SYNC_DAYS = 30;
@@ -25,13 +25,13 @@ class TenantPaddleHealthResource extends Resource
protected static ?string $cluster = DailyOpsCluster::class;
protected static ?string $slug = 'paddle-health';
protected static ?string $slug = 'lemonsqueezy-health';
protected static ?int $navigationSort = 20;
public static function table(Table $table): Table
{
return TenantPaddleHealthTable::configure($table);
return TenantLemonSqueezyHealthTable::configure($table);
}
public static function canCreate(): bool
@@ -41,7 +41,7 @@ class TenantPaddleHealthResource extends Resource
public static function getNavigationLabel(): string
{
return __('admin.paddle_health.navigation.label');
return __('admin.lemonsqueezy_health.navigation.label');
}
public static function getNavigationGroup(): UnitEnum|string|null
@@ -57,31 +57,31 @@ class TenantPaddleHealthResource extends Resource
->with(['activeResellerPackage.package'])
->withExists('activeResellerPackage as has_active_reseller_package')
->addSelect([
'paddle_customer_duplicates' => Tenant::query()
'lemonsqueezy_customer_duplicates' => Tenant::query()
->selectRaw('count(*)')
->whereColumn('paddle_customer_id', 'tenants.paddle_customer_id')
->whereNotNull('paddle_customer_id'),
->whereColumn('lemonsqueezy_customer_id', 'tenants.lemonsqueezy_customer_id')
->whereNotNull('lemonsqueezy_customer_id'),
])
->withCount([
'purchases as paddle_transaction_count' => fn (Builder $query) => $query
->where('provider', 'paddle')
'purchases as lemonsqueezy_transaction_count' => fn (Builder $query) => $query
->where('provider', 'lemonsqueezy')
->where('refunded', false),
'purchases as paddle_transaction_count_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
'purchases as lemonsqueezy_transaction_count_window' => fn (Builder $query) => $query
->where('provider', 'lemonsqueezy')
->where('refunded', false)
->where('purchased_at', '>=', $windowStart),
'purchases as paddle_refund_count_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
'purchases as lemonsqueezy_refund_count_window' => fn (Builder $query) => $query
->where('provider', 'lemonsqueezy')
->where('refunded', true)
->where('purchased_at', '>=', $windowStart),
'checkoutSessions as paddle_checkout_requires_action_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE)
'checkoutSessions as lemonsqueezy_checkout_requires_action_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_LEMONSQUEEZY)
->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION),
'checkoutSessions as paddle_checkout_processing_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE)
'checkoutSessions as lemonsqueezy_checkout_processing_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_LEMONSQUEEZY)
->where('status', CheckoutSession::STATUS_PROCESSING),
'checkoutSessions as paddle_checkout_expired_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE)
'checkoutSessions as lemonsqueezy_checkout_expired_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_LEMONSQUEEZY)
->whereNotIn('status', [
CheckoutSession::STATUS_COMPLETED,
CheckoutSession::STATUS_CANCELLED,
@@ -90,32 +90,32 @@ class TenantPaddleHealthResource extends Resource
->where('expires_at', '<', now()),
])
->withSum([
'purchases as paddle_transaction_total' => fn (Builder $query) => $query
->where('provider', 'paddle')
'purchases as lemonsqueezy_transaction_total' => fn (Builder $query) => $query
->where('provider', 'lemonsqueezy')
->where('refunded', false),
], 'price')
->withSum([
'purchases as paddle_transaction_total_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
'purchases as lemonsqueezy_transaction_total_window' => fn (Builder $query) => $query
->where('provider', 'lemonsqueezy')
->where('refunded', false)
->where('purchased_at', '>=', $windowStart),
], 'price')
->withSum([
'purchases as paddle_refund_total_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
'purchases as lemonsqueezy_refund_total_window' => fn (Builder $query) => $query
->where('provider', 'lemonsqueezy')
->where('refunded', true)
->where('purchased_at', '>=', $windowStart),
], 'price')
->withMax([
'purchases as last_paddle_transaction_at' => fn (Builder $query) => $query
->where('provider', 'paddle'),
'purchases as last_lemonsqueezy_transaction_at' => fn (Builder $query) => $query
->where('provider', 'lemonsqueezy'),
], 'purchased_at');
}
public static function getPages(): array
{
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\Pages\AuditedCreateRecord;
use App\Jobs\SyncCouponToPaddle;
use App\Jobs\SyncCouponToLemonSqueezy;
class CreateCoupon extends AuditedCreateRecord
{
@@ -14,6 +14,6 @@ class CreateCoupon extends AuditedCreateRecord
{
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\Pages\AuditedEditRecord;
use App\Jobs\SyncCouponToPaddle;
use App\Jobs\SyncCouponToLemonSqueezy;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
@@ -27,7 +27,7 @@ class EditCoupon extends AuditedEditRecord
source: static::class
);
SyncCouponToPaddle::dispatch($record, true);
SyncCouponToLemonSqueezy::dispatch($record, true);
}),
ForceDeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
@@ -48,6 +48,6 @@ class EditCoupon extends AuditedEditRecord
{
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
{
return $table
->recordTitleAttribute('paddle_transaction_id')
->recordTitleAttribute('lemonsqueezy_order_id')
->columns([
TextColumn::make('tenant.name')
->label(__('Tenant'))
@@ -65,7 +65,7 @@ class RedemptionsRelationManager extends RelationManager
'failed' => 'danger',
default => 'warning',
}),
TextColumn::make('paddle_transaction_id')
TextColumn::make('lemonsqueezy_order_id')
->label(__('Transaction'))
->copyable()
->toggleable(isToggledHiddenByDefault: true),

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,24 +46,27 @@ class ListGiftVouchers extends ListRecords
])
->action(function (array $data, GiftVoucherService $service): void {
$payload = [
'id' => null,
'metadata' => [
'type' => 'gift_voucher',
'purchaser_email' => $data['purchaser_email'],
'recipient_email' => $data['recipient_email'] ?? null,
'recipient_name' => $data['recipient_name'] ?? null,
'message' => $data['message'] ?? null,
'gift_code' => $data['code'] ?? null,
'meta' => [
'custom_data' => [
'type' => 'gift_voucher',
'purchaser_email' => $data['purchaser_email'],
'recipient_email' => $data['recipient_email'] ?? null,
'recipient_name' => $data['recipient_name'] ?? null,
'message' => $data['message'] ?? null,
'gift_code' => $data['code'] ?? null,
],
],
'currency_code' => $data['currency'] ?? 'EUR',
'totals' => [
'grand_total' => [
'amount' => (float) $data['amount'],
'data' => [
'id' => 'manual_'.Str::uuid(),
'attributes' => [
'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(
'issued',

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,10 +73,10 @@ class TenantResource extends Resource
->email()
->required()
->maxLength(255),
TextInput::make('paddle_customer_id')
->label('Paddle Customer ID')
TextInput::make('lemonsqueezy_customer_id')
->label('Lemon Squeezy Customer ID')
->maxLength(191)
->helperText('Verknuepfung mit Paddle Billing Kundenkonto.')
->helperText('Verknüpfung mit Lemon Squeezy Kundenkonto.')
->nullable(),
TextInput::make('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'),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('contact_email'),
Tables\Columns\TextColumn::make('paddle_customer_id')
->label('Paddle Customer')
Tables\Columns\TextColumn::make('lemonsqueezy_customer_id')
->label('Lemon Squeezy Customer')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
Tables\Columns\TextColumn::make('active_reseller_package_id')

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,7 +36,7 @@ class GiftVoucherCheckoutController extends Controller
if (! $checkout['checkout_url']) {
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
{
$data = $request->validate([
'checkout_id' => ['nullable', 'string', 'required_without_all:transaction_id,code'],
'transaction_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
'code' => ['nullable', 'string', 'required_without_all:checkout_id,transaction_id'],
'checkout_id' => ['nullable', 'string', 'required_without_all:order_id,code'],
'order_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
'code' => ['nullable', 'string', 'required_without_all:checkout_id,order_id'],
]);
$voucherQuery = GiftVoucher::query();
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'])) {
$voucherQuery->orWhere('paddle_transaction_id', $data['transaction_id']);
if (! empty($data['order_id'])) {
$voucherQuery->orWhere('lemonsqueezy_order_id', $data['order_id']);
}
if (! empty($data['code'])) {

View File

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

View File

@@ -13,7 +13,7 @@ class EventAddonCatalogController extends Controller
public function index(): JsonResponse
{
$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]))
->values()
->all();

View File

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

View File

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

View File

@@ -2,27 +2,27 @@
namespace App\Http\Controllers;
use App\Http\Requests\Paddle\PaddleCheckoutRequest;
use App\Http\Requests\LemonSqueezy\LemonSqueezyCheckoutRequest;
use App\Models\CheckoutSession;
use App\Models\Package;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Coupons\CouponService;
use App\Services\Paddle\PaddleCheckoutService;
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use App\Support\CheckoutRequestContext;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class PaddleCheckoutController extends Controller
class LemonSqueezyCheckoutController extends Controller
{
public function __construct(
private readonly PaddleCheckoutService $checkout,
private readonly LemonSqueezyCheckoutService $checkout,
private readonly CheckoutSessionService $sessions,
private readonly CouponService $coupons,
) {}
public function create(PaddleCheckoutRequest $request): JsonResponse
public function create(LemonSqueezyCheckoutRequest $request): JsonResponse
{
$data = $request->validated();
@@ -35,8 +35,8 @@ class PaddleCheckoutController extends Controller
$package = Package::findOrFail((int) $data['package_id']);
if (! $package->paddle_price_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
if (! $package->lemonsqueezy_variant_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
}
$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();
@@ -59,44 +59,10 @@ class PaddleCheckoutController extends Controller
])->save();
$couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? '')));
$discountId = null;
if ($couponCode !== '') {
$preview = $this->coupons->preview($couponCode, $package, $tenant);
$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, [
@@ -108,15 +74,17 @@ class PaddleCheckoutController extends Controller
'legal_version' => $session->legal_version,
'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([
'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([
'paddle_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null,
'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
])),
])->save();

View File

@@ -2,35 +2,32 @@
namespace App\Http\Controllers;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleTransactionService;
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
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
{
$transactionId = $this->resolveTransactionId($request);
$orderId = $this->resolveOrderId($request);
$fallback = $this->resolveFallbackUrl();
if (! $transactionId) {
if (! $orderId) {
return redirect()->to($fallback);
}
try {
$transaction = $this->transactions->retrieve($transactionId);
} catch (PaddleException $exception) {
Log::warning('Paddle return failed to load transaction', [
'transaction_id' => $transactionId,
$order = $this->orders->retrieve($orderId);
} catch (LemonSqueezyException $exception) {
Log::warning('Lemon Squeezy return failed to load order', [
'order_id' => $orderId,
'error' => $exception->getMessage(),
'status' => $exception->status(),
]);
@@ -38,10 +35,10 @@ class PaddleReturnController extends Controller
return redirect()->to($fallback);
}
$customData = $this->extractCustomData($transaction);
$status = Str::lower((string) ($transaction['status'] ?? ''));
$customData = $this->extractCustomData($order);
$status = Str::lower((string) Arr::get($order, 'attributes.status', ''));
$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->resolveSafeRedirect($target, $fallback);
@@ -49,11 +46,10 @@ class PaddleReturnController extends Controller
return redirect()->to($target);
}
protected function resolveTransactionId(Request $request): ?string
protected function resolveOrderId(Request $request): ?string
{
$candidate = $request->query('_ptxn')
?? $request->query('ptxn')
?? $request->query('transaction_id');
$candidate = $request->query('order_id')
?? $request->query('order');
if (! is_string($candidate) || $candidate === '') {
return null;
@@ -68,33 +64,19 @@ class PaddleReturnController extends Controller
}
/**
* @param array<string, mixed> $transaction
* @param array<string, mixed> $order
* @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)) {
$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;
return is_array($customData) ? $customData : [];
}
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

View File

@@ -10,7 +10,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class PaddleWebhookController extends Controller
class LemonSqueezyWebhookController extends Controller
{
public function __construct(
private readonly CheckoutWebhookService $webhooks,
@@ -22,7 +22,7 @@ class PaddleWebhookController extends Controller
{
try {
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);
}
@@ -33,29 +33,27 @@ class PaddleWebhookController extends Controller
return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED);
}
$eventType = $payload['event_type'] ?? null;
$eventId = $payload['event_id'] ?? $payload['id'] ?? data_get($payload, 'data.id');
$eventType = $payload['meta']['event_name'] ?? $request->headers->get('X-Event-Name');
$eventId = $payload['meta']['event_id'] ?? $payload['data']['id'] ?? null;
$webhookEvent = $this->recorder->recordReceived(
'paddle',
'lemonsqueezy',
$eventId ? (string) $eventId : null,
$eventType ? (string) $eventType : null,
);
$handled = false;
$this->logDev('Paddle webhook received', [
$this->logDev('Lemon Squeezy webhook received', [
'event_type' => $eventType,
'checkout_id' => data_get($payload, 'data.checkout_id'),
'transaction_id' => data_get($payload, 'data.id'),
'has_billing_signature' => (string) $request->headers->get('Paddle-Signature', '') !== '',
'has_legacy_signature' => (string) $request->headers->get('Paddle-Webhook-Signature', '') !== '',
'order_id' => data_get($payload, 'data.id'),
'has_signature' => (string) $request->headers->get('X-Signature', '') !== '',
]);
if ($eventType) {
$handled = $this->webhooks->handlePaddleEvent($payload);
$handled = $this->webhooks->handleLemonSqueezyEvent($payload);
$handled = $this->addonWebhooks->handle($payload) || $handled;
}
Log::info('Paddle webhook processed', [
Log::info('Lemon Squeezy webhook processed', [
'event_type' => $eventType,
'handled' => $handled,
]);
@@ -71,13 +69,13 @@ class PaddleWebhookController extends Controller
} catch (\Throwable $exception) {
$eventId = $this->captureWebhookException($exception);
Log::error('Paddle webhook processing failed', [
Log::error('Lemon Squeezy webhook processing failed', [
'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,
]);
$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)) {
$this->recorder->markFailed($webhookEvent, $exception->getMessage());
@@ -89,85 +87,33 @@ class PaddleWebhookController extends Controller
protected function verify(Request $request): bool
{
$secret = config('paddle.webhook_secret');
$secret = config('lemonsqueezy.webhook_secret');
if (! $secret) {
// Allow processing in sandbox or when secret not configured
return true;
}
$billingSignature = (string) $request->headers->get('Paddle-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', '');
$signature = (string) $request->headers->get('X-Signature', '');
if ($signature === '') {
$this->logDev('Paddle webhook missing signature header', [
'header' => 'Paddle-Webhook-Signature',
$this->logDev('Lemon Squeezy webhook missing signature header', [
'header' => 'X-Signature',
]);
return false;
}
$payload = $request->getContent();
$expected = hash_hmac('sha256', $payload, $secret);
$valid = hash_equals($expected, $signature);
if (! $valid) {
$this->logDev('Paddle webhook signature mismatch (legacy)', []);
$this->logDev('Lemon Squeezy webhook signature mismatch', []);
}
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
*/
@@ -177,7 +123,7 @@ class PaddleWebhookController extends Controller
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
{
return array_filter([
'event_type' => $payload['event_type'] ?? null,
'transaction_id' => data_get($payload, 'data.id'),
'checkout_id' => data_get($payload, 'data.checkout_id'),
'status' => data_get($payload, 'data.status'),
'customer_id' => data_get($payload, 'data.customer_id'),
'has_custom_data' => is_array(data_get($payload, 'data.custom_data')),
'event_type' => data_get($payload, 'meta.event_name'),
'order_id' => data_get($payload, 'data.id'),
'status' => data_get($payload, 'data.attributes.status'),
'customer_id' => data_get($payload, 'data.attributes.customer_id'),
'has_custom_data' => is_array(data_get($payload, 'meta.custom_data')),
], static fn ($value) => $value !== null);
}

View File

@@ -13,7 +13,7 @@ use App\Models\TenantPackage;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Coupons\CouponService;
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
use App\Services\Paddle\PaddleCheckoutService;
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use App\Support\CheckoutRequestContext;
use App\Support\CheckoutRoutes;
use App\Support\Concerns\PresentsPackages;
@@ -41,7 +41,7 @@ class MarketingController extends Controller
public function __construct(
private readonly CheckoutSessionService $checkoutSessions,
private readonly PaddleCheckoutService $paddleCheckout,
private readonly LemonSqueezyCheckoutService $lemonsqueezyCheckout,
private readonly CouponService $coupons,
private readonly GiftVoucherCheckoutService $giftVouchers,
) {}
@@ -194,14 +194,14 @@ class MarketingController extends Controller
return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned'));
}
if (! $package->paddle_price_id) {
Log::warning('Package missing Paddle price id', ['package_id' => $package->id]);
if (! $package->lemonsqueezy_variant_id) {
Log::warning('Package missing Lemon Squeezy variant id', ['package_id' => $package->id]);
return redirect()->route('packages', [
'locale' => app()->getLocale(),
'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(
@@ -211,7 +211,7 @@ class MarketingController extends Controller
]
));
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY);
$now = now();
@@ -223,20 +223,17 @@ class MarketingController extends Controller
'legal_version' => $this->resolveLegalVersion(),
])->save();
$appliedDiscountId = null;
if ($couponCode) {
try {
$preview = $this->coupons->preview($couponCode, $package, $tenant);
$this->checkoutSessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
$appliedDiscountId = $preview['coupon']->paddle_discount_id;
$request->session()->forget('marketing.checkout.coupon');
} catch (ValidationException $exception) {
$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', [
'locale' => app()->getLocale(),
'packageId' => $package->id,
@@ -252,15 +249,15 @@ class MarketingController extends Controller
'accepted_terms' => (bool) $session->accepted_terms_at,
'accepted_waiver' => $requiresWaiver && (bool) $session->digital_content_waiver_at,
],
'discount_id' => $appliedDiscountId,
'discount_code' => $couponCode,
]);
$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([
'paddle_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null,
'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
])),
])->save();
@@ -268,7 +265,7 @@ class MarketingController extends Controller
if (! $redirectUrl) {
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,
CheckoutWebhookService $webhooks,
CheckoutSession $session
@@ -70,13 +70,13 @@ class TestCheckoutController extends Controller
$validated = $request->validate([
'event_type' => ['nullable', 'string'],
'transaction_id' => ['nullable', 'string'],
'order_id' => ['nullable', 'string'],
'status' => ['nullable', 'string'],
'checkout_id' => ['nullable', 'string'],
'metadata' => ['nullable', 'array'],
]);
$eventType = $validated['event_type'] ?? 'transaction.completed';
$eventType = $validated['event_type'] ?? 'order_created';
$metadata = array_merge([
'tenant_id' => $session->tenant_id,
'package_id' => $session->package_id,
@@ -84,16 +84,21 @@ class TestCheckoutController extends Controller
], $validated['metadata'] ?? []);
$payload = [
'event_type' => $eventType,
'data' => array_filter([
'id' => $validated['transaction_id'] ?? ('txn_'.Str::uuid()),
'status' => $validated['status'] ?? 'completed',
'meta' => [
'event_name' => $eventType,
'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([
'data' => [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ class SupportTenantResourceRequest extends SupportResourceFormRequest
Rule::unique('tenants', 'slug')->ignore($tenantId),
],
'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_suspended' => ['sometimes', 'boolean'],
'features' => ['sometimes', 'array'],
@@ -31,7 +31,7 @@ class SupportTenantResourceRequest extends SupportResourceFormRequest
return [
'slug',
'contact_email',
'paddle_customer_id',
'lemonsqueezy_customer_id',
'is_active',
'is_suspended',
'features',

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,8 @@
namespace App\Jobs;
use App\Models\PackageAddon;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleAddonCatalogService;
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\LemonSqueezy\LemonSqueezyAddonCatalogService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -14,7 +14,7 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Throwable;
class SyncPackageAddonToPaddle implements ShouldQueue
class SyncPackageAddonToLemonSqueezy implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
@@ -26,7 +26,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue
*/
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);
@@ -39,7 +39,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue
$priceOverrides = Arr::get($this->options, 'price', []);
if ($dryRun) {
$this->storeDryRunSnapshot($catalog, $addon, $productOverrides, $priceOverrides);
$this->storeDryRunSnapshot($addon, $productOverrides, $priceOverrides);
return;
}
@@ -47,41 +47,41 @@ class SyncPackageAddonToPaddle implements ShouldQueue
// Mark syncing (metadata)
$addon->forceFill([
'metadata' => array_merge($addon->metadata ?? [], [
'paddle_sync_status' => 'syncing',
'paddle_synced_at' => now()->toIso8601String(),
'lemonsqueezy_sync_status' => 'syncing',
'lemonsqueezy_synced_at' => now()->toIso8601String(),
]),
])->save();
try {
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
$productResponse = $addon->metadata['paddle_product_id'] ?? null
? $catalog->updateProduct($addon->metadata['paddle_product_id'], $addon, $payloadOverrides['product'])
$productResponse = $addon->metadata['lemonsqueezy_product_id'] ?? null
? $catalog->updateProduct($addon->metadata['lemonsqueezy_product_id'], $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) {
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
? $catalog->updatePrice($addon->price_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId]))
$priceResponse = $addon->variant_id
? $catalog->updatePrice($addon->variant_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId]))
: $catalog->createPrice($addon, $productId, $payloadOverrides['price']);
$priceId = (string) ($priceResponse['id'] ?? $addon->price_id);
$priceId = (string) ($priceResponse['id'] ?? $addon->variant_id);
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([
'price_id' => $priceId,
'variant_id' => $priceId,
'metadata' => array_merge($addon->metadata ?? [], [
'paddle_sync_status' => 'synced',
'paddle_synced_at' => now()->toIso8601String(),
'paddle_product_id' => $productId,
'paddle_snapshot' => [
'lemonsqueezy_sync_status' => 'synced',
'lemonsqueezy_synced_at' => now()->toIso8601String(),
'lemonsqueezy_product_id' => $productId,
'lemonsqueezy_snapshot' => [
'product' => $productResponse,
'price' => $priceResponse,
'payload' => $payloadOverrides,
@@ -89,7 +89,7 @@ class SyncPackageAddonToPaddle implements ShouldQueue
]),
])->save();
} 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,
'message' => $exception->getMessage(),
'exception' => $exception,
@@ -97,9 +97,9 @@ class SyncPackageAddonToPaddle implements ShouldQueue
$addon->forceFill([
'metadata' => array_merge($addon->metadata ?? [], [
'paddle_sync_status' => 'failed',
'paddle_synced_at' => now()->toIso8601String(),
'paddle_error' => [
'lemonsqueezy_sync_status' => 'failed',
'lemonsqueezy_synced_at' => now()->toIso8601String(),
'lemonsqueezy_error' => [
'message' => $exception->getMessage(),
'class' => $exception::class,
],
@@ -145,22 +145,22 @@ class SyncPackageAddonToPaddle implements ShouldQueue
* @param array<string, mixed> $productOverrides
* @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);
$addon->forceFill([
'metadata' => array_merge($addon->metadata ?? [], [
'paddle_sync_status' => 'dry-run',
'paddle_synced_at' => now()->toIso8601String(),
'paddle_snapshot' => [
'lemonsqueezy_sync_status' => 'dry-run',
'lemonsqueezy_synced_at' => now()->toIso8601String(),
'lemonsqueezy_snapshot' => [
'dry_run' => true,
'payload' => $payloadOverrides,
],
]),
])->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,
]);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,11 +33,11 @@ class Package extends Model
'description',
'description_translations',
'description_table',
'paddle_product_id',
'paddle_price_id',
'paddle_sync_status',
'paddle_synced_at',
'paddle_snapshot',
'lemonsqueezy_product_id',
'lemonsqueezy_variant_id',
'lemonsqueezy_sync_status',
'lemonsqueezy_synced_at',
'lemonsqueezy_snapshot',
];
protected $casts = [
@@ -54,8 +54,8 @@ class Package extends Model
'name_translations' => 'array',
'description_translations' => 'array',
'description_table' => 'array',
'paddle_synced_at' => 'datetime',
'paddle_snapshot' => 'array',
'lemonsqueezy_synced_at' => 'datetime',
'lemonsqueezy_snapshot' => 'array',
];
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;
}
public function linkPaddleIds(string $productId, string $priceId): void
public function linkLemonSqueezyIds(string $productId, string $variantId): void
{
$this->forceFill([
'paddle_product_id' => $productId,
'paddle_price_id' => $priceId,
'paddle_sync_status' => 'linked',
'paddle_synced_at' => now(),
'lemonsqueezy_product_id' => $productId,
'lemonsqueezy_variant_id' => $variantId,
'lemonsqueezy_sync_status' => 'linked',
'lemonsqueezy_synced_at' => now(),
])->save();
}

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ class EventAddonCatalog
->mapWithKeys(function (PackageAddon $addon) {
return [$addon->key => [
'label' => $addon->label,
'price_id' => $addon->price_id,
'variant_id' => $addon->variant_id,
'increments' => $addon->increments,
]];
})
@@ -39,11 +39,11 @@ class EventAddonCatalog
return $this->all()[$key] ?? null;
}
public function resolvePriceId(string $key): ?string
public function resolveVariantId(string $key): ?string
{
$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\EventPackageAddon;
use App\Models\Tenant;
use App\Services\Paddle\PaddleClient;
use App\Services\Paddle\PaddleCustomerService;
use Illuminate\Support\Arr;
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Throwable;
class EventAddonCheckoutService
{
public function __construct(
private readonly EventAddonCatalog $catalog,
private readonly PaddleClient $paddle,
private readonly PaddleCustomerService $customers,
private readonly LemonSqueezyCheckoutService $checkout,
) {}
/**
@@ -32,25 +28,17 @@ class EventAddonCheckoutService
$acceptedWaiver = (bool) ($payload['accepted_waiver'] ?? 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)) {
throw ValidationException::withMessages([
'addon_key' => __('Unbekanntes Add-on.'),
]);
}
$priceId = $this->catalog->resolvePriceId($addonKey);
$variantId = $this->catalog->resolveVariantId($addonKey);
if (! $priceId) {
if (! $variantId) {
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_intent' => $addonIntent,
'quantity' => $quantity,
'lemonsqueezy_variant_id' => $variantId,
'legal_version' => $this->resolveLegalVersion(),
'accepted_terms' => $acceptedTerms ? '1' : '0',
'accepted_waiver' => $acceptedWaiver ? '1' : '0',
@@ -80,31 +69,18 @@ class EventAddonCheckoutService
'cancel_url' => $payload['cancel_url'] ?? null,
], static fn ($value) => $value !== null && $value !== '');
$requestPayload = array_filter([
'customer_id' => $customerId,
'items' => [
[
'price_id' => $priceId,
'quantity' => $quantity,
],
],
'custom_data' => $metadata,
], static fn ($value) => $value !== null && $value !== '');
$response = $this->checkout->createVariantCheckout($variantId, $metadata, [
'success_url' => $payload['success_url'] ?? null,
'return_url' => $payload['cancel_url'] ?? null,
'customer_email' => $tenant->contact_email ?? $tenant->user?->email,
]);
$response = $this->paddle->post('/transactions', $requestPayload);
$checkoutUrl = Arr::get($response, 'data.checkout.url')
?? 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');
$checkoutUrl = $response['checkout_url'] ?? null;
$checkoutId = $response['id'] ?? null;
$transactionId = null;
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([
@@ -113,7 +89,7 @@ class EventAddonCheckoutService
'tenant_id' => $tenant->id,
'addon_key' => $addonKey,
'quantity' => $quantity,
'price_id' => $priceId,
'variant_id' => $variantId,
'checkout_id' => $checkoutId,
'transaction_id' => $transactionId,
'status' => 'pending',
@@ -133,10 +109,8 @@ class EventAddonCheckoutService
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' => $transactionId ?? $checkoutId,
'expires_at' => $response['expires_at'] ?? null,
'id' => $checkoutId,
];
}

View File

@@ -17,14 +17,19 @@ class EventAddonWebhookService
public function handle(array $payload): bool
{
$eventType = $payload['event_type'] ?? null;
$eventType = $payload['meta']['event_name'] ?? null;
$data = $payload['data'] ?? [];
if ($eventType !== 'transaction.completed') {
if (! in_array($eventType, ['order_created', 'order_updated'], true)) {
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;
$addonKey = $metadata['addon_key'] ?? null;
@@ -32,8 +37,8 @@ class EventAddonWebhookService
return false;
}
$transactionId = $data['id'] ?? $data['transaction_id'] ?? null;
$checkoutId = $data['checkout_id'] ?? null;
$transactionId = $data['id'] ?? null;
$checkoutId = data_get($data, 'attributes.checkout_id') ?? null;
$addon = EventPackageAddon::query()
->where('addon_key', $addonKey)
@@ -66,10 +71,12 @@ class EventAddonWebhookService
'transaction_id' => $transactionId,
'checkout_id' => $addon->checkout_id ?: $checkoutId,
'status' => 'completed',
'amount' => Arr::get($data, 'totals.grand_total') ?? Arr::get($data, 'amount'),
'currency' => Arr::get($data, 'currency_code') ?? Arr::get($data, 'currency'),
'amount' => $this->resolveAmount($data),
'currency' => Arr::get($data, 'attributes.currency') ?? Arr::get($data, 'currency'),
'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(),
])->save();
@@ -118,17 +125,36 @@ class EventAddonWebhookService
{
$metadata = [];
if (isset($data['metadata']) && is_array($data['metadata'])) {
$metadata = $data['metadata'];
if (isset($data['meta']['custom_data']) && is_array($data['meta']['custom_data'])) {
$metadata = $data['meta']['custom_data'];
}
if (isset($data['custom_data']) && is_array($data['custom_data'])) {
$metadata = array_merge($metadata, $data['custom_data']);
if (isset($data['metadata']) && is_array($data['metadata'])) {
$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;
}
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>
*/

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,16 +8,12 @@ use App\Models\Coupon;
use App\Models\CouponRedemption;
use App\Models\Package;
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\Validation\ValidationException;
class CouponService
{
public function __construct(private readonly PaddleDiscountService $paddleDiscounts) {}
public function __construct() {}
/**
* @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
{
if (! $coupon->paddle_discount_id) {
if (! $coupon->lemonsqueezy_discount_id) {
throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.not_synced'),
]);
@@ -124,58 +120,12 @@ class CouponService
$currency = Str::upper($package->currency ?? 'EUR');
$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 [
'pricing' => $this->manualPricing($coupon, $currency, $subtotal),
'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
{
$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
{
$locale = $this->mapLocale(app()->getLocale());

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
<?php
namespace App\Services\Paddle;
namespace App\Services\LemonSqueezy;
use App\Models\PackageAddon;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
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>
@@ -124,7 +124,7 @@ class PaddleAddonCatalogService
$metaPrice = $addon->metadata['price_eur'] ?? null;
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);

View File

@@ -1,14 +1,14 @@
<?php
namespace App\Services\Paddle;
namespace App\Services\LemonSqueezy;
use App\Models\Package;
use Illuminate\Support\Arr;
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>
@@ -63,7 +63,7 @@ class PaddleCatalogService
{
$payload = $this->buildPricePayload(
$package,
$overrides['product_id'] ?? $package->paddle_product_id,
$overrides['product_id'] ?? $package->lemonsqueezy_product_id,
$overrides,
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
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\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class PaddleClient
class LemonSqueezyClient
{
public function __construct(
private readonly HttpFactory $http,
@@ -42,16 +41,21 @@ class PaddleClient
try {
$response = $request->send(strtoupper($method), ltrim($endpoint, '/'), $options);
} 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()) {
$body = $response->json() ?? [];
$message = Arr::get($body, 'error.message')
$message = Arr::get($body, 'errors.0.detail')
?? Arr::get($body, 'error')
?? 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() ?? [];
@@ -59,23 +63,20 @@ class PaddleClient
protected function preparedRequest(): PendingRequest
{
$apiKey = config('paddle.api_key');
$apiKey = config('lemonsqueezy.api_key');
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'), '/');
$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',
];
$baseUrl = rtrim((string) config('lemonsqueezy.base_url'), '/');
return $this->http
->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)
->acceptJson()
->asJson();

View File

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

View File

@@ -1,34 +1,34 @@
<?php
namespace App\Services\Paddle;
namespace App\Services\LemonSqueezy;
use Illuminate\Support\Arr;
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
* @return array{product_id:string,price_id:string}
* @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,variant_id:string}
*/
public function ensureTier(array $tier): array
{
$product = $tier['paddle_product_id'] ?? null;
$price = $tier['paddle_price_id'] ?? null;
$product = $tier['lemonsqueezy_product_id'] ?? null;
$variant = $tier['lemonsqueezy_variant_id'] ?? null;
if (! $product) {
$product = $this->createProduct($tier)['id'];
}
if (! $price) {
$price = $this->createPrice($tier, $product)['id'];
if (! $variant) {
$variant = $this->createPrice($tier, $product)['id'];
}
return [
'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,
'included_package_slug' => $package->included_package_slug,
'price' => $package->price,
'paddle_product_id' => $package->paddle_product_id,
'paddle_price_id' => $package->paddle_price_id,
'lemonsqueezy_product_id' => $package->lemonsqueezy_product_id,
'lemonsqueezy_variant_id' => $package->lemonsqueezy_variant_id,
'description' => $description,
'description_breakdown' => $table,
'gallery_duration_label' => $galleryDuration,

View File

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

View File

@@ -5,70 +5,70 @@ return [
'reminder_days' => 7,
'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' => [
[
'key' => 'gift-starter',
'label' => 'Geschenk Starter',
'amount' => 29.00,
'currency' => 'EUR',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STARTER', 'pri_01kbwccfe1mpwh7hh60eygemx6'),
'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STARTER'),
],
[
'key' => 'gift-starter-usd',
'label' => 'Gift Starter (USD)',
'amount' => 32.00,
'currency' => 'USD',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STARTER_USD'),
'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STARTER_USD'),
],
[
'key' => 'gift-starter-gbp',
'label' => 'Gift Starter (GBP)',
'amount' => 25.00,
'currency' => 'GBP',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STARTER_GBP'),
'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STARTER_GBP'),
],
[
'key' => 'gift-standard',
'label' => 'Geschenk Classic',
'amount' => 59.00,
'currency' => 'EUR',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD', 'pri_01kbwccfvzrf4z2f1r62vns7gh'),
'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STANDARD'),
],
[
'key' => 'gift-standard-usd',
'label' => 'Gift Classic (USD)',
'amount' => 65.00,
'currency' => 'USD',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_USD'),
'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STANDARD_USD'),
],
[
'key' => 'gift-standard-gbp',
'label' => 'Gift Classic (GBP)',
'amount' => 55.00,
'currency' => 'GBP',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_STANDARD_GBP'),
'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_STANDARD_GBP'),
],
[
'key' => 'gift-premium',
'label' => 'Geschenk Premium',
'amount' => 129.00,
'currency' => 'EUR',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM', 'pri_01kbwccg8vjc5cwz0kftfvf9wm'),
'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_PREMIUM'),
],
[
'key' => 'gift-premium-usd',
'label' => 'Gift Premium (USD)',
'amount' => 139.00,
'currency' => 'USD',
'paddle_price_id' => env('PADDLE_GIFT_PRICE_PREMIUM_USD'),
'lemonsqueezy_variant_id' => env('LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_USD'),
],
[
'key' => 'gift-premium-gbp',
'label' => 'Gift Premium (GBP)',
'amount' => 119.00,
'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),
'replace_placeholders' => true,
],
'paddle-sync' => [
'lemonsqueezy-sync' => [
'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,
],
'paddle-sync-file' => [
'lemonsqueezy-sync-file' => [
'driver' => 'daily',
'path' => storage_path('logs/paddle-sync.log'),
'path' => storage_path('logs/lemonsqueezy-sync.log'),
'level' => env('LOG_LEVEL', 'info'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,

View File

@@ -1,31 +1,31 @@
<?php
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' => [
'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' => [
'extra_photos' => 500,
],
],
'extra_photos_large' => [
'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' => [
'extra_photos' => 2000,
],
],
'extra_guests' => [
'label' => 'Extra guests (100)',
'price_id' => env('PADDLE_ADDON_EXTRA_GUESTS_PRICE_ID'),
'variant_id' => env('LEMONSQUEEZY_ADDON_EXTRA_GUESTS_VARIANT_ID'),
'increments' => [
'extra_guests' => 100,
],
],
'extend_gallery_30d' => [
'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' => [
'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),
],
'paddle' => [
'api_key' => env('PADDLE_API_KEY'),
'client_id' => env('PADDLE_CLIENT_ID'),
'sandbox' => env('PADDLE_SANDBOX', false),
'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'),
'lemonsqueezy' => [
'api_key' => env('LEMONSQUEEZY_API_KEY'),
'store_id' => env('LEMONSQUEEZY_STORE_ID'),
'webhook_secret' => env('LEMONSQUEEZY_WEBHOOK_SECRET'),
'test_mode' => env('LEMONSQUEEZY_TEST_MODE', false),
],
'google' => [

View File

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

View File

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

View File

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

View File

@@ -30,9 +30,9 @@ class PackageFactory extends Factory
'advanced_analytics' => $this->faker->boolean(),
]),
'type' => $this->faker->randomElement(['endcustomer', 'reseller']),
'paddle_sync_status' => null,
'paddle_synced_at' => null,
'paddle_snapshot' => null,
'lemonsqueezy_sync_status' => null,
'lemonsqueezy_synced_at' => 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_customer_id')->nullable();
$table->string('stripe_subscription_id')->nullable();
$table->string('paddle_checkout_id')->nullable();
$table->string('paddle_transaction_id')->nullable();
$table->string('lemonsqueezy_checkout_id')->nullable();
$table->string('lemonsqueezy_order_id')->nullable();
$table->json('provider_metadata')->nullable();
$table->string('locale', 5)->nullable();
@@ -47,8 +47,8 @@ return new class extends Migration
$table->softDeletes();
$table->unique('stripe_payment_intent_id');
$table->unique('paddle_checkout_id');
$table->unique('paddle_transaction_id');
$table->unique('lemonsqueezy_checkout_id');
$table->unique('lemonsqueezy_order_id');
$table->index(['provider', 'status']);
$table->index('expires_at');
});

View File

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

View File

@@ -12,15 +12,15 @@ return new class extends Migration
public function up(): void
{
Schema::table('packages', function (Blueprint $table) {
$table->string('paddle_sync_status', 50)
$table->string('lemonsqueezy_sync_status', 50)
->nullable()
->after('paddle_price_id');
$table->timestamp('paddle_synced_at')
->after('lemonsqueezy_variant_id');
$table->timestamp('lemonsqueezy_synced_at')
->nullable()
->after('paddle_sync_status');
$table->json('paddle_snapshot')
->after('lemonsqueezy_sync_status');
$table->json('lemonsqueezy_snapshot')
->nullable()
->after('paddle_synced_at');
->after('lemonsqueezy_synced_at');
});
}
@@ -30,7 +30,7 @@ return new class extends Migration
public function down(): void
{
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('ends_at')->nullable();
$table->string('paddle_discount_id')->nullable()->unique();
$table->string('paddle_mode', 40)->default('standard');
$table->json('paddle_snapshot')->nullable();
$table->timestamp('paddle_last_synced_at')->nullable();
$table->string('lemonsqueezy_discount_id')->nullable()->unique();
$table->string('lemonsqueezy_mode', 40)->default('standard');
$table->json('lemonsqueezy_snapshot')->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, 'updated_by')->nullable()->constrained('users')->nullOnDelete();

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