Migrate billing from Paddle to Lemon Squeezy
This commit is contained in:
24
.env.example
24
.env.example
@@ -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
|
||||
|
||||
@@ -3599,6 +3599,8 @@ var tokens3 = {
|
||||
// ... existing radius tokens ...
|
||||
card: 16,
|
||||
tile: 14,
|
||||
bento: 24,
|
||||
bentoLg: 32,
|
||||
pill: 999
|
||||
}
|
||||
// ...
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
130
app/Console/Commands/LemonSqueezyRegisterWebhooks.php
Normal file
130
app/Console/Commands/LemonSqueezyRegisterWebhooks.php
Normal 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' : '';
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
]),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -14,6 +14,6 @@ class VerifyCsrfToken extends Middleware
|
||||
protected $except = [
|
||||
'api/v1/photos/*/like',
|
||||
'api/v1/events/*/upload',
|
||||
'paddle/webhook*',
|
||||
'lemonsqueezy/webhook*',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -20,7 +20,7 @@ class EventPackageAddon extends Model
|
||||
'extra_photos',
|
||||
'extra_guests',
|
||||
'extra_gallery_days',
|
||||
'price_id',
|
||||
'variant_id',
|
||||
'checkout_id',
|
||||
'transaction_id',
|
||||
'status',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class PackageAddon extends Model
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'label',
|
||||
'price_id',
|
||||
'variant_id',
|
||||
'extra_photos',
|
||||
'extra_guests',
|
||||
'extra_gallery_days',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}]");
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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 = [])
|
||||
{
|
||||
@@ -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);
|
||||
@@ -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
|
||||
);
|
||||
128
app/Services/LemonSqueezy/LemonSqueezyCheckoutService.php
Normal file
128
app/Services/LemonSqueezy/LemonSqueezyCheckoutService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
151
app/Services/LemonSqueezy/LemonSqueezyOrderService.php
Normal file
151
app/Services/LemonSqueezy/LemonSqueezyOrderService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
12
config/lemonsqueezy.php
Normal 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'),
|
||||
];
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user