Migrate billing from Paddle to Lemon Squeezy

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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