coupon code system eingeführt. coupons werden vom super admin gemanaged. coupons werden mit paddle synchronisiert und dort validiert. plus: einige mobil-optimierungen im tenant admin pwa.
This commit is contained in:
56
app/Console/Commands/ExportCouponRedemptions.php
Normal file
56
app/Console/Commands/ExportCouponRedemptions.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CouponRedemption;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ExportCouponRedemptions extends Command
|
||||
{
|
||||
protected $signature = 'coupons:export {--days=30 : Number of days to include} {--path= : Relative path inside storage/app}';
|
||||
|
||||
protected $description = 'Export coupon redemptions within the given timeframe to CSV.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = max(1, (int) $this->option('days'));
|
||||
$since = now()->subDays($days);
|
||||
|
||||
$redemptions = CouponRedemption::query()
|
||||
->where('status', CouponRedemption::STATUS_SUCCESS)
|
||||
->where('redeemed_at', '>=', $since)
|
||||
->with(['coupon', 'tenant', 'package'])
|
||||
->orderBy('redeemed_at')
|
||||
->get();
|
||||
|
||||
$rows = [
|
||||
['coupon_code', 'tenant_id', 'package_id', 'amount_discounted', 'currency', 'redeemed_at'],
|
||||
];
|
||||
|
||||
foreach ($redemptions as $redemption) {
|
||||
$rows[] = [
|
||||
$redemption->coupon?->code ?? '',
|
||||
$redemption->tenant_id,
|
||||
$redemption->package_id,
|
||||
number_format((float) $redemption->amount_discounted, 2, '.', ''),
|
||||
$redemption->currency,
|
||||
optional($redemption->redeemed_at)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
$csv = collect($rows)
|
||||
->map(fn (array $row) => collect($row)
|
||||
->map(fn ($value) => '"'.str_replace('"', '""', (string) $value).'"')
|
||||
->implode(','))
|
||||
->implode(PHP_EOL)
|
||||
.PHP_EOL;
|
||||
|
||||
$path = $this->option('path') ?: 'reports/coupon-redemptions-'.now()->format('Ymd_His').'.csv';
|
||||
Storage::disk('local')->put($path, $csv);
|
||||
|
||||
$this->info(sprintf('Exported %d records to %s', $redemptions->count(), storage_path('app/'.$path)));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
23
app/Enums/CouponStatus.php
Normal file
23
app/Enums/CouponStatus.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum CouponStatus: string
|
||||
{
|
||||
case DRAFT = 'draft';
|
||||
case ACTIVE = 'active';
|
||||
case SCHEDULED = 'scheduled';
|
||||
case PAUSED = 'paused';
|
||||
case ARCHIVED = 'archived';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DRAFT => __('Draft'),
|
||||
self::ACTIVE => __('Active'),
|
||||
self::SCHEDULED => __('Scheduled'),
|
||||
self::PAUSED => __('Paused'),
|
||||
self::ARCHIVED => __('Archived'),
|
||||
};
|
||||
}
|
||||
}
|
||||
19
app/Enums/CouponType.php
Normal file
19
app/Enums/CouponType.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum CouponType: string
|
||||
{
|
||||
case PERCENTAGE = 'percentage';
|
||||
case FLAT = 'flat';
|
||||
case FLAT_PER_SEAT = 'flat_per_seat';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::PERCENTAGE => __('Percentage'),
|
||||
self::FLAT => __('Flat amount'),
|
||||
self::FLAT_PER_SEAT => __('Flat per seat'),
|
||||
};
|
||||
}
|
||||
}
|
||||
123
app/Filament/Resources/Coupons/CouponResource.php
Normal file
123
app/Filament/Resources/Coupons/CouponResource.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Coupons;
|
||||
|
||||
use App\Filament\Resources\Coupons\Pages\CreateCoupon;
|
||||
use App\Filament\Resources\Coupons\Pages\EditCoupon;
|
||||
use App\Filament\Resources\Coupons\Pages\ListCoupons;
|
||||
use App\Filament\Resources\Coupons\Pages\ViewCoupon;
|
||||
use App\Filament\Resources\Coupons\RelationManagers\RedemptionsRelationManager;
|
||||
use App\Filament\Resources\Coupons\Schemas\CouponForm;
|
||||
use App\Filament\Resources\Coupons\Schemas\CouponInfolist;
|
||||
use App\Filament\Resources\Coupons\Tables\CouponsTable;
|
||||
use App\Models\Coupon;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class CouponResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Coupon::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTicket;
|
||||
|
||||
protected static ?int $navigationSort = 60;
|
||||
|
||||
protected static ?string $navigationLabel = 'Coupons';
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return __('Billing & Finanzen');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return CouponForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return CouponInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return CouponsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
RedemptionsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListCoupons::route('/'),
|
||||
'create' => CreateCoupon::route('/create'),
|
||||
'view' => ViewCoupon::route('/{record}'),
|
||||
'edit' => EditCoupon::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getRecordRouteBindingEloquentQuery()
|
||||
->withoutGlobalScopes([
|
||||
SoftDeletingScope::class,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->withoutGlobalScopes([
|
||||
SoftDeletingScope::class,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$userId = Auth::id();
|
||||
if ($userId) {
|
||||
$data['created_by'] = $userId;
|
||||
$data['updated_by'] = $userId;
|
||||
}
|
||||
|
||||
return static::sanitizeFormData($data);
|
||||
}
|
||||
|
||||
public static function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
if ($userId = Auth::id()) {
|
||||
$data['updated_by'] = $userId;
|
||||
}
|
||||
|
||||
return static::sanitizeFormData($data);
|
||||
}
|
||||
|
||||
protected static function sanitizeFormData(array $data): array
|
||||
{
|
||||
if (! empty($data['code'])) {
|
||||
$data['code'] = strtoupper($data['code']);
|
||||
}
|
||||
|
||||
if (($data['type'] ?? null) === 'percentage') {
|
||||
$data['currency'] = null;
|
||||
} elseif (! empty($data['currency'])) {
|
||||
$data['currency'] = strtoupper($data['currency']);
|
||||
}
|
||||
|
||||
if (empty($data['metadata'])) {
|
||||
$data['metadata'] = null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
17
app/Filament/Resources/Coupons/Pages/CreateCoupon.php
Normal file
17
app/Filament/Resources/Coupons/Pages/CreateCoupon.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Coupons\Pages;
|
||||
|
||||
use App\Filament\Resources\Coupons\CouponResource;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateCoupon extends CreateRecord
|
||||
{
|
||||
protected static string $resource = CouponResource::class;
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
SyncCouponToPaddle::dispatch($this->record);
|
||||
}
|
||||
}
|
||||
32
app/Filament/Resources/Coupons/Pages/EditCoupon.php
Normal file
32
app/Filament/Resources/Coupons/Pages/EditCoupon.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Coupons\Pages;
|
||||
|
||||
use App\Filament\Resources\Coupons\CouponResource;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
use Filament\Actions\RestoreAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditCoupon extends EditRecord
|
||||
{
|
||||
protected static string $resource = CouponResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
ViewAction::make(),
|
||||
DeleteAction::make()
|
||||
->after(fn ($record) => SyncCouponToPaddle::dispatch($record, true)),
|
||||
ForceDeleteAction::make(),
|
||||
RestoreAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
SyncCouponToPaddle::dispatch($this->record);
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/Coupons/Pages/ListCoupons.php
Normal file
19
app/Filament/Resources/Coupons/Pages/ListCoupons.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Coupons\Pages;
|
||||
|
||||
use App\Filament\Resources\Coupons\CouponResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListCoupons extends ListRecords
|
||||
{
|
||||
protected static string $resource = CouponResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/Coupons/Pages/ViewCoupon.php
Normal file
19
app/Filament/Resources/Coupons/Pages/ViewCoupon.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Coupons\Pages;
|
||||
|
||||
use App\Filament\Resources\Coupons\CouponResource;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewCoupon extends ViewRecord
|
||||
{
|
||||
protected static string $resource = CouponResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Coupons\RelationManagers;
|
||||
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class RedemptionsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'redemptions';
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('paddle_transaction_id')
|
||||
->columns([
|
||||
TextColumn::make('tenant.name')
|
||||
->label(__('Tenant'))
|
||||
->searchable(),
|
||||
TextColumn::make('user.name')
|
||||
->label(__('User'))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('amount_discounted')
|
||||
->label(__('Discount'))
|
||||
->formatStateUsing(fn ($state, $record) => sprintf('%s %s', number_format($record->amount_discounted, 2), $record->currency))
|
||||
->sortable(),
|
||||
TextColumn::make('status')
|
||||
->label(__('Status'))
|
||||
->badge()
|
||||
->color(fn ($state) => match ($state) {
|
||||
'success' => 'success',
|
||||
'failed' => 'danger',
|
||||
default => 'warning',
|
||||
}),
|
||||
TextColumn::make('paddle_transaction_id')
|
||||
->label(__('Transaction'))
|
||||
->copyable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('redeemed_at')
|
||||
->label(__('Redeemed at'))
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
TextColumn::make('metadata')
|
||||
->label(__('Metadata'))
|
||||
->formatStateUsing(fn ($state) => $state ? json_encode($state, JSON_PRETTY_PRINT) : '—')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->copyable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->label(__('Status'))
|
||||
->options([
|
||||
'pending' => __('Pending'),
|
||||
'success' => __('Success'),
|
||||
'failed' => __('Failed'),
|
||||
]),
|
||||
])
|
||||
->headerActions([
|
||||
// read-only
|
||||
])
|
||||
->recordActions([])
|
||||
->toolbarActions([]);
|
||||
}
|
||||
}
|
||||
148
app/Filament/Resources/Coupons/Schemas/CouponForm.php
Normal file
148
app/Filament/Resources/Coupons/Schemas/CouponForm.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Coupons\Schemas;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Validation\Rules\Unique;
|
||||
|
||||
class CouponForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
$typeOptions = collect(CouponType::cases())
|
||||
->mapWithKeys(fn (CouponType $type) => [$type->value => $type->label()])
|
||||
->all();
|
||||
|
||||
$statusOptions = collect(CouponStatus::cases())
|
||||
->mapWithKeys(fn (CouponStatus $status) => [$status->value => $status->label()])
|
||||
->all();
|
||||
|
||||
return $schema
|
||||
->columns(1)
|
||||
->components([
|
||||
Section::make(__('Coupon details'))
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label(__('Name'))
|
||||
->maxLength(191)
|
||||
->required(),
|
||||
TextInput::make('code')
|
||||
->label(__('Code'))
|
||||
->maxLength(32)
|
||||
->required()
|
||||
->helperText(__('Codes are stored uppercase and must be unique.'))
|
||||
->unique(ignoreRecord: true, modifyRuleUsing: fn (Unique $rule) => $rule->withoutTrashed())
|
||||
->rule('alpha_dash:ascii'),
|
||||
Select::make('status')
|
||||
->label(__('Status'))
|
||||
->options($statusOptions)
|
||||
->default(CouponStatus::DRAFT)
|
||||
->required(),
|
||||
Select::make('type')
|
||||
->label(__('Discount Type'))
|
||||
->options($typeOptions)
|
||||
->default(CouponType::PERCENTAGE)
|
||||
->required(),
|
||||
TextInput::make('amount')
|
||||
->label(__('Amount'))
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->step(0.01)
|
||||
->required(),
|
||||
TextInput::make('currency')
|
||||
->label(__('Currency'))
|
||||
->maxLength(3)
|
||||
->default('EUR')
|
||||
->helperText(__('Required for flat discounts. Hidden for percentage codes.'))
|
||||
->disabled(fn (Get $get) => $get('type') === CouponType::PERCENTAGE->value)
|
||||
->nullable(),
|
||||
Textarea::make('description')
|
||||
->label(__('Description'))
|
||||
->rows(3)
|
||||
->columnSpanFull(),
|
||||
Toggle::make('enabled_for_checkout')
|
||||
->label(__('Can be redeemed at checkout'))
|
||||
->default(true),
|
||||
Toggle::make('auto_apply')
|
||||
->label(__('Auto apply when query parameter matches'))
|
||||
->helperText(__('Automatically applies when marketing links include ?coupon=CODE.')),
|
||||
Toggle::make('is_stackable')
|
||||
->label(__('Allow stacking with other coupons'))
|
||||
->default(false),
|
||||
]),
|
||||
Section::make(__('Limits & Scheduling'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
DateTimePicker::make('starts_at')
|
||||
->label(__('Starts at'))
|
||||
->seconds(false)
|
||||
->nullable(),
|
||||
DateTimePicker::make('ends_at')
|
||||
->label(__('Ends at'))
|
||||
->seconds(false)
|
||||
->nullable(),
|
||||
TextInput::make('usage_limit')
|
||||
->label(__('Global usage limit'))
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->nullable(),
|
||||
TextInput::make('per_customer_limit')
|
||||
->label(__('Per tenant limit'))
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->nullable(),
|
||||
]),
|
||||
Section::make(__('Package restrictions'))
|
||||
->schema([
|
||||
Select::make('packages')
|
||||
->label(__('Applicable packages'))
|
||||
->relationship('packages', 'name')
|
||||
->multiple()
|
||||
->preload()
|
||||
->searchable()
|
||||
->helperText(__('Leave empty to allow all packages.')),
|
||||
]),
|
||||
Section::make(__('Metadata'))
|
||||
->schema([
|
||||
KeyValue::make('metadata')
|
||||
->label(__('Internal metadata'))
|
||||
->keyLabel(__('Key'))
|
||||
->valueLabel(__('Value'))
|
||||
->nullable()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Section::make(__('Paddle sync'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('paddle_mode')
|
||||
->label(__('Paddle 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')
|
||||
->label(__('Last synced'))
|
||||
->content(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
|
||||
Placeholder::make('redemptions_count')
|
||||
->label(__('Total redemptions'))
|
||||
->content(fn ($record) => number_format($record?->redemptions_count ?? 0)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
81
app/Filament/Resources/Coupons/Schemas/CouponInfolist.php
Normal file
81
app/Filament/Resources/Coupons/Schemas/CouponInfolist.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Coupons\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\KeyValueEntry;
|
||||
use Filament\Infolists\Components\Section;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CouponInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(__('Summary'))
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextEntry::make('name')->label(__('Name')),
|
||||
TextEntry::make('code')->label(__('Code'))->copyable(),
|
||||
TextEntry::make('status')
|
||||
->label(__('Status'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state) => Str::headline($state)),
|
||||
TextEntry::make('type')
|
||||
->label(__('Discount type'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state) => Str::headline($state)),
|
||||
TextEntry::make('amount')
|
||||
->label(__('Amount'))
|
||||
->formatStateUsing(fn ($state, $record) => $record?->type?->value === 'percentage'
|
||||
? sprintf('%s%%', $record?->amount ?? $state)
|
||||
: sprintf('%s %s', number_format((float) ($record?->amount ?? $state), 2), strtoupper($record?->currency ?? 'EUR'))),
|
||||
TextEntry::make('redemptions_count')
|
||||
->label(__('Total redemptions'))
|
||||
->badge(),
|
||||
]),
|
||||
Section::make(__('Limits & scheduling'))
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextEntry::make('usage_limit')->label(__('Global usage limit'))->placeholder('—'),
|
||||
TextEntry::make('per_customer_limit')->label(__('Per tenant limit'))->placeholder('—'),
|
||||
TextEntry::make('starts_at')
|
||||
->label(__('Starts'))
|
||||
->dateTime(),
|
||||
TextEntry::make('ends_at')
|
||||
->label(__('Ends'))
|
||||
->dateTime(),
|
||||
]),
|
||||
Section::make(__('Packages'))
|
||||
->schema([
|
||||
TextEntry::make('packages_list')
|
||||
->label(__('Limited to packages'))
|
||||
->state(fn ($record) => $record?->packages?->pluck('name')->all() ?: null)
|
||||
->listWithLineBreaks()
|
||||
->placeholder(__('All packages')),
|
||||
]),
|
||||
Section::make(__('Metadata'))
|
||||
->schema([
|
||||
TextEntry::make('description')->label(__('Description'))->columnSpanFull(),
|
||||
KeyValueEntry::make('metadata')->label(__('Metadata'))->columnSpanFull(),
|
||||
]),
|
||||
Section::make(__('Paddle'))
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextEntry::make('paddle_discount_id')
|
||||
->label(__('Discount ID'))
|
||||
->copyable()
|
||||
->placeholder('—'),
|
||||
TextEntry::make('paddle_last_synced_at')
|
||||
->label(__('Last synced'))
|
||||
->state(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
|
||||
TextEntry::make('paddle_mode')
|
||||
->label(__('Mode'))
|
||||
->badge()
|
||||
->placeholder('standard'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
113
app/Filament/Resources/Coupons/Tables/CouponsTable.php
Normal file
113
app/Filament/Resources/Coupons/Tables/CouponsTable.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Coupons\Tables;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ForceDeleteBulkAction;
|
||||
use Filament\Actions\RestoreBulkAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CouponsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(__('Name'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('code')
|
||||
->label(__('Code'))
|
||||
->badge()
|
||||
->copyable()
|
||||
->sortable(),
|
||||
TextColumn::make('type')
|
||||
->label(__('Type'))
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state) => Str::headline($state))
|
||||
->sortable(),
|
||||
TextColumn::make('amount')
|
||||
->label(__('Amount'))
|
||||
->formatStateUsing(fn ($state, $record) => $record->type === CouponType::PERCENTAGE
|
||||
? sprintf('%s%%', $record->amount)
|
||||
: sprintf('%s %s', number_format($record->amount, 2), strtoupper($record->currency ?? 'EUR')))
|
||||
->sortable(),
|
||||
TextColumn::make('redemptions_count')
|
||||
->label(__('Redeemed'))
|
||||
->badge()
|
||||
->sortable(),
|
||||
IconColumn::make('enabled_for_checkout')
|
||||
->label(__('Checkout'))
|
||||
->boolean(),
|
||||
TextColumn::make('status')
|
||||
->label(__('Status'))
|
||||
->badge()
|
||||
->sortable()
|
||||
->formatStateUsing(fn ($state) => Str::headline($state)),
|
||||
TextColumn::make('starts_at')
|
||||
->label(__('Starts'))
|
||||
->date()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('ends_at')
|
||||
->label(__('Ends'))
|
||||
->date()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->label(__('Status'))
|
||||
->options(
|
||||
collect(CouponStatus::cases())->mapWithKeys(fn ($status) => [$status->value => $status->label()])->all()
|
||||
),
|
||||
SelectFilter::make('type')
|
||||
->label(__('Type'))
|
||||
->options(
|
||||
collect(CouponType::cases())->mapWithKeys(fn ($type) => [$type->value => $type->label()])->all()
|
||||
),
|
||||
TernaryFilter::make('is_active')
|
||||
->label(__('Currently active'))
|
||||
->placeholder(__('All'))
|
||||
->queries(
|
||||
true: fn ($query) => $query->active(),
|
||||
false: fn ($query) => $query->where(function ($inner) {
|
||||
$inner->where('status', '!=', CouponStatus::ACTIVE->value)
|
||||
->orWhere(function ($sub) {
|
||||
$sub->whereNotNull('ends_at')
|
||||
->where('ends_at', '<', now());
|
||||
});
|
||||
}),
|
||||
),
|
||||
TrashedFilter::make(),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
Action::make('sync')
|
||||
->label(__('Sync to Paddle'))
|
||||
->icon('heroicon-m-arrow-path')
|
||||
->action(fn ($record) => SyncCouponToPaddle::dispatch($record))
|
||||
->requiresConfirmation(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
ForceDeleteBulkAction::make(),
|
||||
RestoreBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
60
app/Filament/Widgets/CouponUsageWidget.php
Normal file
60
app/Filament/Widgets/CouponUsageWidget.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Coupon;
|
||||
use App\Models\CouponRedemption;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Widgets\TableWidget as BaseWidget;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class CouponUsageWidget extends BaseWidget
|
||||
{
|
||||
protected static ?int $sort = 7;
|
||||
|
||||
protected function getHeading(): string
|
||||
{
|
||||
return __('Coupon performance (30d)');
|
||||
}
|
||||
|
||||
protected function getTableQuery(): Builder
|
||||
{
|
||||
$since = now()->subDays(30);
|
||||
|
||||
return Coupon::query()
|
||||
->withCount(['redemptions as recent_redemptions_count' => function (Builder $query) use ($since) {
|
||||
$query->where('status', CouponRedemption::STATUS_SUCCESS)
|
||||
->where('redeemed_at', '>=', $since);
|
||||
}])
|
||||
->withSum(['redemptions as recent_discount_sum' => function (Builder $query) use ($since) {
|
||||
$query->where('status', CouponRedemption::STATUS_SUCCESS)
|
||||
->where('redeemed_at', '>=', $since);
|
||||
}], 'amount_discounted')
|
||||
->orderByDesc('recent_discount_sum')
|
||||
->limit(5);
|
||||
}
|
||||
|
||||
protected function getTableColumns(): array
|
||||
{
|
||||
return [
|
||||
TextColumn::make('code')
|
||||
->label(__('Code'))
|
||||
->badge(),
|
||||
TextColumn::make('status')
|
||||
->label(__('Status'))
|
||||
->badge(),
|
||||
TextColumn::make('recent_redemptions_count')
|
||||
->label(__('Uses (30d)'))
|
||||
->sortable(),
|
||||
TextColumn::make('recent_discount_sum')
|
||||
->label(__('Discount (30d)'))
|
||||
->formatStateUsing(function ($state, Coupon $record) {
|
||||
$currency = $record->currency ?? 'EUR';
|
||||
$value = (float) ($state ?? 0);
|
||||
|
||||
return sprintf('%s %0.2f', $currency, $value);
|
||||
})
|
||||
->sortable(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Package;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CouponPreviewController extends Controller
|
||||
{
|
||||
public function __construct(private readonly CouponService $coupons) {}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'code' => ['required', 'string', 'max:64'],
|
||||
'package_id' => ['required', 'integer', 'exists:packages,id'],
|
||||
]);
|
||||
|
||||
$package = Package::findOrFail($data['package_id']);
|
||||
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.package_not_configured'),
|
||||
]);
|
||||
}
|
||||
|
||||
$tenant = Auth::user()?->tenant;
|
||||
|
||||
try {
|
||||
$preview = $this->coupons->preview($data['code'], $package, $tenant);
|
||||
} catch (ValidationException $exception) {
|
||||
Log::warning('Coupon preview denied', [
|
||||
'code' => $data['code'],
|
||||
'package_id' => $package->id,
|
||||
'tenant_id' => $tenant?->id,
|
||||
'errors' => $exception->errors(),
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
Log::info('Coupon preview success', [
|
||||
'code' => $preview['coupon']->code,
|
||||
'package_id' => $package->id,
|
||||
'tenant_id' => $tenant?->id,
|
||||
'discount' => $preview['pricing']['discount'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'coupon' => [
|
||||
'id' => $preview['coupon']->id,
|
||||
'code' => $preview['coupon']->code,
|
||||
'type' => $preview['coupon']->type?->value,
|
||||
'amount' => (float) $preview['coupon']->amount,
|
||||
'currency' => $preview['coupon']->currency,
|
||||
'description' => $preview['coupon']->description,
|
||||
'expires_at' => $preview['coupon']->ends_at?->toIso8601String(),
|
||||
'is_stackable' => $preview['coupon']->is_stackable,
|
||||
],
|
||||
'pricing' => $preview['pricing'],
|
||||
'package' => [
|
||||
'id' => $package->id,
|
||||
'name' => $package->name,
|
||||
'price' => (float) $package->price,
|
||||
'currency' => $package->currency ?? 'EUR',
|
||||
],
|
||||
'source' => $preview['source'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use App\Http\Requests\Tenant\EmotionStoreRequest;
|
||||
use App\Http\Requests\Tenant\EmotionUpdateRequest;
|
||||
use App\Http\Resources\Tenant\EmotionResource;
|
||||
use App\Models\Emotion;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\TenantRequestResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
@@ -16,7 +18,7 @@ class EmotionController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$tenantId = $request->tenant->id;
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
$query = Emotion::query()
|
||||
->whereNull('tenant_id')
|
||||
@@ -41,9 +43,10 @@ class EmotionController extends Controller
|
||||
public function store(EmotionStoreRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
$payload = [
|
||||
'tenant_id' => $request->tenant->id,
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $this->localizeValue($data['name']),
|
||||
'description' => $this->localizeValue($data['description'] ?? null, allowNull: true),
|
||||
'icon' => $data['icon'] ?? 'lucide-smile',
|
||||
@@ -70,7 +73,9 @@ class EmotionController extends Controller
|
||||
|
||||
public function update(EmotionUpdateRequest $request, Emotion $emotion): JsonResponse
|
||||
{
|
||||
if ($emotion->tenant_id && $emotion->tenant_id !== $request->tenant->id) {
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
if ($emotion->tenant_id && $emotion->tenant_id !== $tenantId) {
|
||||
abort(403, 'Emotion gehört nicht zu diesem Tenant.');
|
||||
}
|
||||
|
||||
@@ -139,6 +144,7 @@ class EmotionController extends Controller
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
$locale = app()->getLocale() ?: 'de';
|
||||
|
||||
return [$locale => $value];
|
||||
}
|
||||
|
||||
@@ -149,9 +155,14 @@ class EmotionController extends Controller
|
||||
{
|
||||
$normalized = ltrim($color, '#');
|
||||
if (strlen($normalized) === 6) {
|
||||
return '#' . strtolower($normalized);
|
||||
return '#'.strtolower($normalized);
|
||||
}
|
||||
|
||||
return '#6366f1';
|
||||
}
|
||||
|
||||
protected function currentTenant(Request $request): Tenant
|
||||
{
|
||||
return TenantRequestResolver::resolve($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ class SettingsController extends Controller
|
||||
}
|
||||
|
||||
$taken = Tenant::where('custom_domain', $domain)
|
||||
->where('id', '!=', $request->tenant->id)
|
||||
->where('id', '!=', $this->resolveTenant($request)->id)
|
||||
->exists();
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -6,17 +6,19 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Tenant\TaskCollectionResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Tenant\TaskCollectionImportService;
|
||||
use App\Support\TenantRequestResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TaskCollectionController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$tenantId = $request->tenant->id;
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
$query = TaskCollection::query()
|
||||
->forTenant($tenantId)
|
||||
@@ -68,11 +70,11 @@ class TaskCollectionController extends Controller
|
||||
$this->authorizeAccess($request, $collection);
|
||||
|
||||
$data = $request->validate([
|
||||
'event_slug' => ['required', 'string', Rule::exists('events', 'slug')->where('tenant_id', $request->tenant->id)],
|
||||
'event_slug' => ['required', 'string', Rule::exists('events', 'slug')->where('tenant_id', $this->currentTenant($request)->id)],
|
||||
]);
|
||||
|
||||
$event = Event::where('slug', $data['event_slug'])
|
||||
->where('tenant_id', $request->tenant->id)
|
||||
->where('tenant_id', $this->currentTenant($request)->id)
|
||||
->firstOrFail();
|
||||
|
||||
$result = $importService->import($collection, $event);
|
||||
@@ -87,8 +89,13 @@ class TaskCollectionController extends Controller
|
||||
|
||||
protected function authorizeAccess(Request $request, TaskCollection $collection): void
|
||||
{
|
||||
if ($collection->tenant_id && $collection->tenant_id !== $request->tenant->id) {
|
||||
if ($collection->tenant_id && $collection->tenant_id !== $this->currentTenant($request)->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
protected function currentTenant(Request $request): Tenant
|
||||
{
|
||||
return TenantRequestResolver::resolve($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ use App\Http\Resources\Tenant\TaskResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\Task;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\ApiError;
|
||||
use App\Support\TenantRequestResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
@@ -22,14 +24,14 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$tenantId = $request->tenant->id;
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
$query = Task::query()
|
||||
->where(function ($inner) use ($tenantId) {
|
||||
$inner->whereNull('tenant_id')
|
||||
->orWhere('tenant_id', $tenantId);
|
||||
})
|
||||
->with(['taskCollection', 'assignedEvents'])
|
||||
->with(['taskCollection', 'assignedEvents', 'eventType'])
|
||||
->orderByRaw('tenant_id is null desc')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('created_at', 'desc');
|
||||
@@ -64,11 +66,12 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function store(TaskStoreRequest $request): JsonResponse
|
||||
{
|
||||
$tenant = $this->currentTenant($request);
|
||||
$collectionId = $request->input('collection_id');
|
||||
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
|
||||
|
||||
$payload = $this->prepareTaskPayload($request->validated(), $request->tenant->id);
|
||||
$payload['tenant_id'] = $request->tenant->id;
|
||||
$payload = $this->prepareTaskPayload($request->validated(), $tenant->id);
|
||||
$payload['tenant_id'] = $tenant->id;
|
||||
|
||||
if ($collection) {
|
||||
$payload['collection_id'] = $collection->id;
|
||||
@@ -77,7 +80,7 @@ class TaskController extends Controller
|
||||
|
||||
$task = Task::create($payload);
|
||||
|
||||
$task->load(['taskCollection', 'assignedEvents']);
|
||||
$task->load(['taskCollection', 'assignedEvents', 'eventType']);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Task erfolgreich erstellt.',
|
||||
@@ -90,11 +93,11 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function show(Request $request, Task $task): JsonResponse
|
||||
{
|
||||
if ($task->tenant_id && $task->tenant_id !== $request->tenant->id) {
|
||||
if ($task->tenant_id && $task->tenant_id !== $this->currentTenant($request)->id) {
|
||||
abort(404, 'Task nicht gefunden.');
|
||||
}
|
||||
|
||||
$task->load(['taskCollection', 'assignedEvents']);
|
||||
$task->load(['taskCollection', 'assignedEvents', 'eventType']);
|
||||
|
||||
return response()->json(new TaskResource($task));
|
||||
}
|
||||
@@ -104,14 +107,16 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
|
||||
{
|
||||
if ($task->tenant_id !== $request->tenant->id) {
|
||||
$tenant = $this->currentTenant($request);
|
||||
|
||||
if ($task->tenant_id !== $tenant->id) {
|
||||
abort(404, 'Task nicht gefunden.');
|
||||
}
|
||||
|
||||
$collectionId = $request->input('collection_id');
|
||||
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
|
||||
|
||||
$payload = $this->prepareTaskPayload($request->validated(), $request->tenant->id, $task);
|
||||
$payload = $this->prepareTaskPayload($request->validated(), $tenant->id, $task);
|
||||
|
||||
if ($collection) {
|
||||
$payload['collection_id'] = $collection->id;
|
||||
@@ -133,7 +138,7 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function destroy(Request $request, Task $task): JsonResponse
|
||||
{
|
||||
if ($task->tenant_id !== $request->tenant->id) {
|
||||
if ($task->tenant_id !== $this->currentTenant($request)->id) {
|
||||
abort(404, 'Task nicht gefunden.');
|
||||
}
|
||||
|
||||
@@ -149,7 +154,9 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
|
||||
{
|
||||
if ($task->tenant_id !== $request->tenant->id || $event->tenant_id !== $request->tenant->id) {
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
if ($task->tenant_id !== $tenantId || $event->tenant_id !== $tenantId) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@@ -169,7 +176,9 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
if ($event->tenant_id !== $request->tenant->id) {
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@@ -184,7 +193,7 @@ class TaskController extends Controller
|
||||
}
|
||||
|
||||
$tasks = Task::whereIn('id', $taskIds)
|
||||
->where('tenant_id', $request->tenant->id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->get();
|
||||
|
||||
$attached = 0;
|
||||
@@ -205,12 +214,12 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function forEvent(Request $request, Event $event): AnonymousResourceCollection
|
||||
{
|
||||
if ($event->tenant_id !== $request->tenant->id) {
|
||||
if ($event->tenant_id !== $this->currentTenant($request)->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tasks = Task::whereHas('assignedEvents', fn ($q) => $q->where('event_id', $event->id))
|
||||
->with(['taskCollection'])
|
||||
->with(['taskCollection', 'eventType'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate($request->get('per_page', 15));
|
||||
|
||||
@@ -222,12 +231,12 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function fromCollection(Request $request, TaskCollection $collection): AnonymousResourceCollection
|
||||
{
|
||||
if ($collection->tenant_id && $collection->tenant_id !== $request->tenant->id) {
|
||||
if ($collection->tenant_id && $collection->tenant_id !== $this->currentTenant($request)->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tasks = $collection->tasks()
|
||||
->with(['assignedEvents'])
|
||||
->with(['assignedEvents', 'eventType'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate($request->get('per_page', 15));
|
||||
|
||||
@@ -240,13 +249,20 @@ class TaskController extends Controller
|
||||
->where(function ($query) use ($request) {
|
||||
$query->whereNull('tenant_id');
|
||||
|
||||
if ($request->tenant?->id) {
|
||||
$query->orWhere('tenant_id', $request->tenant->id);
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
if ($tenantId) {
|
||||
$query->orWhere('tenant_id', $tenantId);
|
||||
}
|
||||
})
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
protected function currentTenant(Request $request): Tenant
|
||||
{
|
||||
return TenantRequestResolver::resolve($request);
|
||||
}
|
||||
|
||||
protected function prepareTaskPayload(array $data, int $tenantId, ?Task $original = null): array
|
||||
{
|
||||
if (array_key_exists('title', $data)) {
|
||||
|
||||
@@ -9,12 +9,14 @@ use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
@@ -32,6 +34,7 @@ class MarketingController extends Controller
|
||||
public function __construct(
|
||||
private readonly CheckoutSessionService $checkoutSessions,
|
||||
private readonly PaddleCheckoutService $paddleCheckout,
|
||||
private readonly CouponService $coupons,
|
||||
) {}
|
||||
|
||||
public function index()
|
||||
@@ -107,8 +110,10 @@ class MarketingController extends Controller
|
||||
Log::info('Buy packages called', ['auth' => Auth::check(), 'locale' => $locale, 'package_id' => $packageId]);
|
||||
$package = Package::findOrFail($packageId);
|
||||
|
||||
$couponCode = $this->rememberCouponFromRequest($request, $package);
|
||||
|
||||
if (! Auth::check()) {
|
||||
return redirect()->route('register', ['package_id' => $package->id])
|
||||
return redirect()->route('register', ['package_id' => $package->id, 'coupon' => $couponCode])
|
||||
->with('message', __('marketing.packages.register_required'));
|
||||
}
|
||||
|
||||
@@ -167,6 +172,19 @@ class MarketingController extends Controller
|
||||
|
||||
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
$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, [
|
||||
'success_url' => route('marketing.success', [
|
||||
'locale' => app()->getLocale(),
|
||||
@@ -178,7 +196,9 @@ class MarketingController extends Controller
|
||||
]),
|
||||
'metadata' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
'coupon_code' => $couponCode,
|
||||
],
|
||||
'discount_id' => $appliedDiscountId,
|
||||
]);
|
||||
|
||||
$session->forceFill([
|
||||
@@ -210,6 +230,34 @@ class MarketingController extends Controller
|
||||
return Inertia::render('marketing/Success', compact('packageId'));
|
||||
}
|
||||
|
||||
protected function rememberCouponFromRequest(Request $request, Package $package): ?string
|
||||
{
|
||||
$input = Str::upper(trim((string) $request->input('coupon')));
|
||||
|
||||
if ($input !== '') {
|
||||
$request->session()->put('marketing.checkout.coupon', [
|
||||
'package_id' => $package->id,
|
||||
'code' => $input,
|
||||
]);
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
if ($request->has('coupon')) {
|
||||
$request->session()->forget('marketing.checkout.coupon');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$stored = $request->session()->get('marketing.checkout.coupon');
|
||||
|
||||
if ($stored && (int) ($stored['package_id'] ?? 0) === (int) $package->id) {
|
||||
return $stored['code'] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function blogIndex(Request $request)
|
||||
{
|
||||
$locale = $request->get('locale', app()->getLocale());
|
||||
|
||||
@@ -5,10 +5,12 @@ namespace App\Http\Controllers;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PaddleCheckoutController extends Controller
|
||||
@@ -16,6 +18,7 @@ class PaddleCheckoutController extends Controller
|
||||
public function __construct(
|
||||
private readonly PaddleCheckoutService $checkout,
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
private readonly CouponService $coupons,
|
||||
) {}
|
||||
|
||||
public function create(Request $request): JsonResponse
|
||||
@@ -25,6 +28,7 @@ class PaddleCheckoutController extends Controller
|
||||
'success_url' => ['nullable', 'url'],
|
||||
'return_url' => ['nullable', 'url'],
|
||||
'inline' => ['sometimes', 'boolean'],
|
||||
'coupon_code' => ['nullable', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
@@ -46,7 +50,16 @@ class PaddleCheckoutController extends Controller
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
if ($request->boolean('inline')) {
|
||||
$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',
|
||||
]);
|
||||
@@ -80,7 +93,9 @@ class PaddleCheckoutController extends Controller
|
||||
'return_url' => $data['return_url'] ?? null,
|
||||
'metadata' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
'coupon_code' => $couponCode ?: null,
|
||||
],
|
||||
'discount_id' => $discountId,
|
||||
]);
|
||||
|
||||
$session->forceFill([
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use App\Support\TenantRequestResolver;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -22,12 +23,12 @@ class TaskStoreRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$tenantId = TenantRequestResolver::resolve($this)->id;
|
||||
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'collection_id' => ['nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) {
|
||||
$tenantId = request()->tenant?->id;
|
||||
|
||||
'collection_id' => ['nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) use ($tenantId) {
|
||||
$accessible = \App\Models\TaskCollection::where('id', $value)
|
||||
->where(function ($query) use ($tenantId) {
|
||||
$query->whereNull('tenant_id');
|
||||
@@ -45,9 +46,8 @@ class TaskStoreRequest extends FormRequest
|
||||
'priority' => ['nullable', Rule::in(['low', 'medium', 'high', 'urgent'])],
|
||||
'due_date' => ['nullable', 'date', 'after:now'],
|
||||
'is_completed' => ['nullable', 'boolean'],
|
||||
'assigned_to' => ['nullable', 'exists:users,id', function ($attribute, $value, $fail) {
|
||||
$tenantId = request()->tenant?->id;
|
||||
if ($tenantId && !\App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
|
||||
'assigned_to' => ['nullable', 'exists:users,id', function ($attribute, $value, $fail) use ($tenantId) {
|
||||
if ($tenantId && ! \App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
|
||||
$fail('Der Benutzer gehört nicht zu diesem Tenant.');
|
||||
}
|
||||
}],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use App\Support\TenantRequestResolver;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -22,12 +23,12 @@ class TaskUpdateRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$tenantId = TenantRequestResolver::resolve($this)->id;
|
||||
|
||||
return [
|
||||
'title' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'description' => ['sometimes', 'nullable', 'string'],
|
||||
'collection_id' => ['sometimes', 'nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) {
|
||||
$tenantId = request()->tenant?->id;
|
||||
|
||||
'collection_id' => ['sometimes', 'nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) use ($tenantId) {
|
||||
$accessible = \App\Models\TaskCollection::where('id', $value)
|
||||
->where(function ($query) use ($tenantId) {
|
||||
$query->whereNull('tenant_id');
|
||||
@@ -45,9 +46,8 @@ class TaskUpdateRequest extends FormRequest
|
||||
'priority' => ['sometimes', 'nullable', Rule::in(['low', 'medium', 'high', 'urgent'])],
|
||||
'due_date' => ['sometimes', 'nullable', 'date'],
|
||||
'is_completed' => ['sometimes', 'boolean'],
|
||||
'assigned_to' => ['sometimes', 'nullable', 'exists:users,id', function ($attribute, $value, $fail) {
|
||||
$tenantId = request()->tenant?->id;
|
||||
if ($tenantId && !\App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
|
||||
'assigned_to' => ['sometimes', 'nullable', 'exists:users,id', function ($attribute, $value, $fail) use ($tenantId) {
|
||||
if ($tenantId && ! \App\Models\User::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
|
||||
$fail('Der Benutzer gehört nicht zu diesem Tenant.');
|
||||
}
|
||||
}],
|
||||
|
||||
@@ -36,6 +36,11 @@ class TaskResource extends JsonResource
|
||||
'difficulty' => $this->difficulty,
|
||||
'due_date' => $this->due_date?->toISOString(),
|
||||
'is_completed' => (bool) $this->is_completed,
|
||||
'event_type_id' => $this->event_type_id,
|
||||
'event_type' => $this->whenLoaded(
|
||||
'eventType',
|
||||
fn () => new EventTypeResource($this->eventType)
|
||||
),
|
||||
'collection_id' => $this->collection_id,
|
||||
'source_task_id' => $this->source_task_id,
|
||||
'source_collection_id' => $this->source_collection_id,
|
||||
|
||||
68
app/Jobs/SyncCouponToPaddle.php
Normal file
68
app/Jobs/SyncCouponToPaddle.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Coupon;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use App\Services\Paddle\PaddleDiscountService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SyncCouponToPaddle implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Coupon $coupon,
|
||||
public bool $archive = false,
|
||||
) {}
|
||||
|
||||
public function handle(PaddleDiscountService $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(),
|
||||
])->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$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(),
|
||||
])->save();
|
||||
} catch (PaddleException $exception) {
|
||||
Log::error('Failed syncing coupon to Paddle', [
|
||||
'coupon_id' => $this->coupon->id,
|
||||
'message' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
'context' => $exception->context(),
|
||||
]);
|
||||
|
||||
$this->coupon->forceFill([
|
||||
'paddle_snapshot' => [
|
||||
'error' => $exception->getMessage(),
|
||||
'status' => $exception->status(),
|
||||
'context' => $exception->context(),
|
||||
],
|
||||
])->save();
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,13 @@ class CheckoutSession extends Model
|
||||
'package_snapshot' => 'array',
|
||||
'status_history' => 'array',
|
||||
'provider_metadata' => 'array',
|
||||
'coupon_snapshot' => 'array',
|
||||
'discount_breakdown' => 'array',
|
||||
'expires_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'amount_subtotal' => 'decimal:2',
|
||||
'amount_total' => 'decimal:2',
|
||||
'amount_discount' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -71,6 +74,8 @@ class CheckoutSession extends Model
|
||||
'status_history' => '[]',
|
||||
'package_snapshot' => '[]',
|
||||
'provider_metadata' => '[]',
|
||||
'coupon_snapshot' => '[]',
|
||||
'discount_breakdown' => '[]',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
@@ -88,6 +93,11 @@ class CheckoutSession extends Model
|
||||
return $this->belongsTo(Package::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function coupon(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Coupon::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->whereNotIn('status', [
|
||||
|
||||
189
app/Models/Coupon.php
Normal file
189
app/Models/Coupon.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Coupon extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\CouponFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'code',
|
||||
'type',
|
||||
'amount',
|
||||
'currency',
|
||||
'status',
|
||||
'is_stackable',
|
||||
'enabled_for_checkout',
|
||||
'auto_apply',
|
||||
'usage_limit',
|
||||
'per_customer_limit',
|
||||
'redemptions_count',
|
||||
'description',
|
||||
'metadata',
|
||||
'starts_at',
|
||||
'ends_at',
|
||||
'paddle_discount_id',
|
||||
'paddle_mode',
|
||||
'paddle_snapshot',
|
||||
'paddle_last_synced_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'type' => CouponType::class,
|
||||
'status' => CouponStatus::class,
|
||||
'amount' => 'decimal:2',
|
||||
'is_stackable' => 'boolean',
|
||||
'enabled_for_checkout' => 'boolean',
|
||||
'auto_apply' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
'paddle_snapshot' => 'array',
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
'paddle_last_synced_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $coupon): void {
|
||||
if ($coupon->code) {
|
||||
$coupon->code = Str::upper($coupon->code);
|
||||
}
|
||||
|
||||
if ($coupon->currency) {
|
||||
$coupon->currency = Str::upper($coupon->currency);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updatedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
public function packages(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Package::class)->withTimestamps();
|
||||
}
|
||||
|
||||
public function checkoutSessions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CheckoutSession::class);
|
||||
}
|
||||
|
||||
public function redemptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CouponRedemption::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', CouponStatus::ACTIVE)
|
||||
->where(function ($inner) {
|
||||
$now = now();
|
||||
|
||||
$inner->whereNull('starts_at')->orWhere('starts_at', '<=', $now);
|
||||
})
|
||||
->where(function ($inner) {
|
||||
$now = now();
|
||||
|
||||
$inner->whereNull('ends_at')->orWhere('ends_at', '>=', $now);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeAutoApply($query)
|
||||
{
|
||||
return $query->where('auto_apply', true);
|
||||
}
|
||||
|
||||
public function remainingUsages(?int $customerUsage = null): ?int
|
||||
{
|
||||
$globalRemaining = $this->usage_limit !== null
|
||||
? max($this->usage_limit - $this->redemptions_count, 0)
|
||||
: null;
|
||||
|
||||
$customerRemaining = $this->per_customer_limit !== null && $customerUsage !== null
|
||||
? max($this->per_customer_limit - $customerUsage, 0)
|
||||
: null;
|
||||
|
||||
if ($globalRemaining === null) {
|
||||
return $customerRemaining;
|
||||
}
|
||||
|
||||
if ($customerRemaining === null) {
|
||||
return $globalRemaining;
|
||||
}
|
||||
|
||||
return min($globalRemaining, $customerRemaining);
|
||||
}
|
||||
|
||||
public function isCurrentlyActive(): bool
|
||||
{
|
||||
if ($this->status !== CouponStatus::ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
if ($this->starts_at && $this->starts_at->isFuture()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->ends_at && $this->ends_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->usage_limit !== null && $this->redemptions_count >= $this->usage_limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function appliesToPackage(?Package $package): bool
|
||||
{
|
||||
if (! $package) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->relationLoaded('packages')) {
|
||||
return $this->packages->isEmpty() || $this->packages->contains(fn (Package $pkg) => $pkg->is($package));
|
||||
}
|
||||
|
||||
$count = $this->packages()->count();
|
||||
if ($count === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->packages()->whereKey($package->getKey())->exists();
|
||||
}
|
||||
|
||||
protected function code(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (?string $value) => $value ? Str::upper($value) : $value,
|
||||
set: fn (?string $value) => $value ? Str::upper($value) : $value,
|
||||
);
|
||||
}
|
||||
}
|
||||
84
app/Models/CouponRedemption.php
Normal file
84
app/Models/CouponRedemption.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CouponRedemption extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\CouponRedemptionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_SUCCESS = 'success';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'coupon_id',
|
||||
'checkout_session_id',
|
||||
'package_id',
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'paddle_transaction_id',
|
||||
'status',
|
||||
'failure_reason',
|
||||
'amount_discounted',
|
||||
'currency',
|
||||
'metadata',
|
||||
'redeemed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount_discounted' => 'decimal:2',
|
||||
'metadata' => 'array',
|
||||
'redeemed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function coupon(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Coupon::class);
|
||||
}
|
||||
|
||||
public function checkoutSession(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CheckoutSession::class);
|
||||
}
|
||||
|
||||
public function package(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Package::class);
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function markSuccessful(?float $amount = null): void
|
||||
{
|
||||
if ($amount !== null) {
|
||||
$this->amount_discounted = $amount;
|
||||
}
|
||||
|
||||
$this->status = self::STATUS_SUCCESS;
|
||||
$this->failure_reason = null;
|
||||
$this->redeemed_at = now();
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function markFailed(string $reason): void
|
||||
{
|
||||
$this->status = self::STATUS_FAILED;
|
||||
$this->failure_reason = $reason;
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
@@ -96,6 +97,11 @@ class Package extends Model
|
||||
return $this->hasMany(PackagePurchase::class);
|
||||
}
|
||||
|
||||
public function coupons(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Coupon::class)->withTimestamps();
|
||||
}
|
||||
|
||||
public function isEndcustomer(): bool
|
||||
{
|
||||
return $this->type === 'endcustomer';
|
||||
|
||||
@@ -132,6 +132,13 @@ class AppServiceProvider extends ServiceProvider
|
||||
return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown'));
|
||||
});
|
||||
|
||||
RateLimiter::for('coupon-preview', function (Request $request) {
|
||||
$code = strtoupper((string) $request->input('code'));
|
||||
$identifier = ($request->ip() ?? 'unknown').($code ? ':'.$code : '');
|
||||
|
||||
return Limit::perMinute(10)->by('coupon-preview:'.$identifier);
|
||||
});
|
||||
|
||||
Inertia::share('locale', fn () => app()->getLocale());
|
||||
Inertia::share('analytics', static function () {
|
||||
$config = config('services.matomo');
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use App\Filament\Blog\Resources\CategoryResource;
|
||||
use App\Filament\Blog\Resources\PostResource;
|
||||
use App\Filament\Resources\LegalPageResource;
|
||||
use App\Filament\Widgets\CreditAlertsWidget;
|
||||
use App\Filament\Widgets\PlatformStatsWidget;
|
||||
use App\Filament\Widgets\RevenueTrendWidget;
|
||||
use App\Filament\Widgets\TopTenantsByRevenue;
|
||||
use App\Filament\Widgets\TopTenantsByUploads;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
@@ -10,7 +18,6 @@ use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Widgets;
|
||||
use App\Filament\Resources\LegalPageResource;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
@@ -18,20 +25,12 @@ use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\AuthenticateSession;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
use App\Filament\Widgets\CreditAlertsWidget;
|
||||
use App\Filament\Widgets\PlatformStatsWidget;
|
||||
use App\Filament\Widgets\RevenueTrendWidget;
|
||||
use App\Filament\Widgets\TopTenantsByUploads;
|
||||
use App\Filament\Widgets\TopTenantsByRevenue;
|
||||
use App\Filament\Blog\Resources\PostResource;
|
||||
use App\Filament\Blog\Resources\CategoryResource;
|
||||
use App\Filament\Blog\Resources\AuthorResource;
|
||||
|
||||
class SuperAdminPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
|
||||
|
||||
return $panel
|
||||
->default()
|
||||
->id('superadmin')
|
||||
@@ -56,6 +55,7 @@ class SuperAdminPanelProvider extends PanelProvider
|
||||
CreditAlertsWidget::class,
|
||||
RevenueTrendWidget::class,
|
||||
PlatformStatsWidget::class,
|
||||
\App\Filament\Widgets\CouponUsageWidget::class,
|
||||
TopTenantsByRevenue::class,
|
||||
TopTenantsByUploads::class,
|
||||
\App\Filament\Widgets\StorageCapacityWidget::class,
|
||||
@@ -85,10 +85,9 @@ class SuperAdminPanelProvider extends PanelProvider
|
||||
CategoryResource::class,
|
||||
LegalPageResource::class,
|
||||
])
|
||||
->authGuard('web')
|
||||
|
||||
// SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation
|
||||
// Blog-Resources werden durch das Plugin-ServiceProvider automatisch registriert
|
||||
;
|
||||
->authGuard('web');
|
||||
|
||||
// SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation
|
||||
// Blog-Resources werden durch das Plugin-ServiceProvider automatisch registriert
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Checkout;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@@ -64,6 +65,7 @@ class CheckoutSessionService
|
||||
$session->package_snapshot = $this->packageSnapshot($package);
|
||||
$session->amount_subtotal = Arr::get($session->package_snapshot, 'price', 0);
|
||||
$session->amount_total = Arr::get($session->package_snapshot, 'price', 0);
|
||||
$session->amount_discount = 0;
|
||||
$session->provider = CheckoutSession::PROVIDER_NONE;
|
||||
$session->status = CheckoutSession::STATUS_DRAFT;
|
||||
$session->stripe_payment_intent_id = null;
|
||||
@@ -73,6 +75,10 @@ class CheckoutSessionService
|
||||
$session->paddle_transaction_id = null;
|
||||
$session->provider_metadata = [];
|
||||
$session->failure_reason = null;
|
||||
$session->coupon()->dissociate();
|
||||
$session->coupon_code = null;
|
||||
$session->coupon_snapshot = [];
|
||||
$session->discount_breakdown = [];
|
||||
$session->expires_at = now()->addMinutes($this->sessionTtlMinutes);
|
||||
$this->appendStatus($session, CheckoutSession::STATUS_DRAFT, 'package_switched');
|
||||
$session->save();
|
||||
@@ -81,6 +87,31 @@ class CheckoutSessionService
|
||||
});
|
||||
}
|
||||
|
||||
public function applyCoupon(CheckoutSession $session, Coupon $coupon, array $pricing): CheckoutSession
|
||||
{
|
||||
$snapshot = [
|
||||
'coupon' => [
|
||||
'id' => $coupon->id,
|
||||
'code' => $coupon->code,
|
||||
'type' => $coupon->type?->value,
|
||||
],
|
||||
'pricing' => $pricing,
|
||||
];
|
||||
|
||||
$session->coupon()->associate($coupon);
|
||||
$session->coupon_code = $coupon->code;
|
||||
$session->coupon_snapshot = $snapshot;
|
||||
$session->amount_subtotal = $pricing['subtotal'] ?? $session->amount_subtotal;
|
||||
$session->amount_discount = $pricing['discount'] ?? 0;
|
||||
$session->amount_total = $pricing['total'] ?? $session->amount_total;
|
||||
$session->discount_breakdown = is_array($pricing['breakdown'] ?? null)
|
||||
? $pricing['breakdown']
|
||||
: [];
|
||||
$session->save();
|
||||
|
||||
return $session->refresh();
|
||||
}
|
||||
|
||||
public function selectProvider(CheckoutSession $session, string $provider): CheckoutSession
|
||||
{
|
||||
$provider = strtolower($provider);
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Coupons\CouponRedemptionService;
|
||||
use App\Services\Paddle\PaddleSubscriptionService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -19,6 +20,7 @@ class CheckoutWebhookService
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
private readonly CheckoutAssignmentService $assignment,
|
||||
private readonly PaddleSubscriptionService $paddleSubscriptions,
|
||||
private readonly CouponRedemptionService $couponRedemptions,
|
||||
) {}
|
||||
|
||||
public function handleStripeEvent(array $event): bool
|
||||
@@ -216,6 +218,7 @@ class CheckoutWebhookService
|
||||
]);
|
||||
|
||||
$this->sessions->markCompleted($session, now());
|
||||
$this->couponRedemptions->recordSuccess($session, $data);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -224,6 +227,7 @@ class CheckoutWebhookService
|
||||
case 'transaction.cancelled':
|
||||
$reason = $status ?: ($eventType === 'transaction.failed' ? 'paddle_failed' : 'paddle_cancelled');
|
||||
$this->sessions->markFailed($session, $reason);
|
||||
$this->couponRedemptions->recordFailure($session, $reason);
|
||||
|
||||
return true;
|
||||
|
||||
|
||||
72
app/Services/Coupons/CouponRedemptionService.php
Normal file
72
app/Services/Coupons/CouponRedemptionService.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Coupons;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\CouponRedemption;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class CouponRedemptionService
|
||||
{
|
||||
public function recordSuccess(CheckoutSession $session, array $payload = []): void
|
||||
{
|
||||
if (! $session->coupon_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$transactionId = Arr::get($payload, 'id') ?? $session->paddle_transaction_id;
|
||||
|
||||
$values = [
|
||||
'tenant_id' => $session->tenant_id,
|
||||
'user_id' => $session->user_id,
|
||||
'package_id' => $session->package_id,
|
||||
'paddle_transaction_id' => $transactionId,
|
||||
'status' => CouponRedemption::STATUS_SUCCESS,
|
||||
'failure_reason' => null,
|
||||
'amount_discounted' => $session->amount_discount,
|
||||
'currency' => $session->currency ?? 'EUR',
|
||||
'metadata' => array_filter([
|
||||
'session_snapshot' => $session->coupon_snapshot,
|
||||
'payload' => $payload,
|
||||
]),
|
||||
'redeemed_at' => now(),
|
||||
];
|
||||
|
||||
CouponRedemption::query()->updateOrCreate(
|
||||
[
|
||||
'coupon_id' => $session->coupon_id,
|
||||
'checkout_session_id' => $session->id,
|
||||
],
|
||||
$values,
|
||||
);
|
||||
|
||||
$session->coupon?->increment('redemptions_count');
|
||||
}
|
||||
|
||||
public function recordFailure(CheckoutSession $session, string $reason): void
|
||||
{
|
||||
if (! $session->coupon_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
CouponRedemption::query()->updateOrCreate(
|
||||
[
|
||||
'coupon_id' => $session->coupon_id,
|
||||
'checkout_session_id' => $session->id,
|
||||
],
|
||||
[
|
||||
'tenant_id' => $session->tenant_id,
|
||||
'user_id' => $session->user_id,
|
||||
'package_id' => $session->package_id,
|
||||
'paddle_transaction_id' => $session->paddle_transaction_id,
|
||||
'status' => CouponRedemption::STATUS_FAILED,
|
||||
'failure_reason' => $reason,
|
||||
'amount_discounted' => $session->amount_discount,
|
||||
'currency' => $session->currency ?? 'EUR',
|
||||
'metadata' => array_filter([
|
||||
'session_snapshot' => $session->coupon_snapshot,
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
287
app/Services/Coupons/CouponService.php
Normal file
287
app/Services/Coupons/CouponService.php
Normal file
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Coupons;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
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) {}
|
||||
|
||||
/**
|
||||
* @return array{coupon: Coupon, pricing: array<string, mixed>, source: string}
|
||||
*/
|
||||
public function preview(string $code, Package $package, ?Tenant $tenant = null): array
|
||||
{
|
||||
$coupon = $this->findCouponForCode($code);
|
||||
|
||||
$this->ensureCouponCanBeApplied($coupon, $package, $tenant);
|
||||
|
||||
$pricing = $this->buildPricingBreakdown($coupon, $package, $tenant);
|
||||
|
||||
return [
|
||||
'coupon' => $coupon,
|
||||
'pricing' => $pricing['pricing'],
|
||||
'source' => $pricing['source'],
|
||||
];
|
||||
}
|
||||
|
||||
public function ensureCouponCanBeApplied(Coupon $coupon, Package $package, ?Tenant $tenant = null): void
|
||||
{
|
||||
if (! $coupon->paddle_discount_id) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.not_synced'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $coupon->enabled_for_checkout) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.disabled'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $coupon->isCurrentlyActive()) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.inactive'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $coupon->appliesToPackage($package)) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.not_applicable'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($tenant) {
|
||||
$usage = $this->usageForTenant($coupon, $tenant);
|
||||
$remaining = $coupon->remainingUsages($usage);
|
||||
|
||||
if ($remaining !== null && $remaining <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.limit_reached'),
|
||||
]);
|
||||
}
|
||||
} elseif ($coupon->per_customer_limit !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.login_required'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function findCouponForCode(string $code): Coupon
|
||||
{
|
||||
$normalized = Str::upper(trim($code));
|
||||
|
||||
if ($normalized === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.required'),
|
||||
]);
|
||||
}
|
||||
|
||||
$coupon = Coupon::query()
|
||||
->where('code', $normalized)
|
||||
->first();
|
||||
|
||||
if (! $coupon) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.not_found'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($coupon->status, [CouponStatus::PAUSED, CouponStatus::ARCHIVED, CouponStatus::DRAFT], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.inactive'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $coupon;
|
||||
}
|
||||
|
||||
protected function usageForTenant(Coupon $coupon, Tenant $tenant): int
|
||||
{
|
||||
return $coupon->redemptions()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereNot('status', CouponRedemption::STATUS_FAILED)
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{pricing: array<string, mixed>, source: string}
|
||||
*/
|
||||
protected function buildPricingBreakdown(Coupon $coupon, Package $package, ?Tenant $tenant = null): array
|
||||
{
|
||||
$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) {
|
||||
CouponType::PERCENTAGE => round($subtotal * ((float) $coupon->amount) / 100, 2),
|
||||
default => (float) $coupon->amount,
|
||||
};
|
||||
|
||||
if ($coupon->type !== CouponType::PERCENTAGE && $coupon->currency && Str::upper($coupon->currency) !== $currency) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => __('marketing.coupon.errors.currency_mismatch'),
|
||||
]);
|
||||
}
|
||||
|
||||
$discount = min($discount, $subtotal);
|
||||
$total = max($subtotal - $discount, 0);
|
||||
|
||||
return $this->formatPricing($currency, $subtotal, $discount, 0, $total, [
|
||||
'breakdown' => [
|
||||
['type' => 'coupon', 'amount' => $discount],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
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());
|
||||
$formatter = class_exists(\NumberFormatter::class)
|
||||
? new \NumberFormatter($locale, \NumberFormatter::CURRENCY)
|
||||
: null;
|
||||
|
||||
$format = function (float $amount, bool $allowNegative = false) use ($currency, $formatter): string {
|
||||
$value = $allowNegative ? $amount : max($amount, 0);
|
||||
|
||||
if ($formatter) {
|
||||
$formatted = $formatter->formatCurrency($value, $currency);
|
||||
if ($formatted !== false) {
|
||||
return $formatted;
|
||||
}
|
||||
}
|
||||
|
||||
$symbol = match ($currency) {
|
||||
'EUR' => '€',
|
||||
'USD' => '$',
|
||||
default => $currency.' ',
|
||||
};
|
||||
|
||||
return $symbol.number_format($value, 2, ',', '.');
|
||||
};
|
||||
|
||||
return array_merge([
|
||||
'currency' => $currency,
|
||||
'subtotal' => round($subtotal, 2),
|
||||
'discount' => round($discount, 2),
|
||||
'tax' => round($tax, 2),
|
||||
'total' => round($total, 2),
|
||||
'formatted' => [
|
||||
'subtotal' => $format($subtotal),
|
||||
'discount' => $format(-1 * abs($discount), true),
|
||||
'tax' => $format($tax),
|
||||
'total' => $format($total),
|
||||
],
|
||||
], $extra);
|
||||
}
|
||||
|
||||
protected function mapLocale(?string $locale): string
|
||||
{
|
||||
return match ($locale) {
|
||||
'de', 'de_DE' => 'de_DE',
|
||||
'en', 'en_GB', 'en_US' => 'en_US',
|
||||
default => 'en_US',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ class PaddleCheckoutService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{success_url?: string|null, return_url?: string|null} $options
|
||||
* @param array{success_url?: string|null, return_url?: string|null, discount_id?: string|null, metadata?: array} $options
|
||||
*/
|
||||
public function createCheckout(Tenant $tenant, Package $package, array $options = []): array
|
||||
{
|
||||
@@ -46,6 +46,10 @@ class PaddleCheckoutService
|
||||
'cancel_url' => $returnUrl,
|
||||
];
|
||||
|
||||
if (! empty($options['discount_id'])) {
|
||||
$payload['discount_id'] = $options['discount_id'];
|
||||
}
|
||||
|
||||
if ($tenant->contact_email) {
|
||||
$payload['customer_email'] = $tenant->contact_email;
|
||||
}
|
||||
|
||||
149
app/Services/Paddle/PaddleDiscountService.php
Normal file
149
app/Services/Paddle/PaddleDiscountService.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle;
|
||||
|
||||
use App\Enums\CouponType;
|
||||
use App\Models\Coupon;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PaddleDiscountService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createDiscount(Coupon $coupon): array
|
||||
{
|
||||
$payload = $this->buildDiscountPayload($coupon);
|
||||
|
||||
$response = $this->client->post('/discounts', $payload);
|
||||
|
||||
return Arr::get($response, 'data', $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updateDiscount(Coupon $coupon): array
|
||||
{
|
||||
if (! $coupon->paddle_discount_id) {
|
||||
return $this->createDiscount($coupon);
|
||||
}
|
||||
|
||||
$payload = $this->buildDiscountPayload($coupon);
|
||||
|
||||
$response = $this->client->patch('/discounts/'.$coupon->paddle_discount_id, $payload);
|
||||
|
||||
return Arr::get($response, 'data', $response);
|
||||
}
|
||||
|
||||
public function archiveDiscount(Coupon $coupon): void
|
||||
{
|
||||
if (! $coupon->paddle_discount_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->client->delete('/discounts/'.$coupon->paddle_discount_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{price_id: string, quantity?: int}> $items
|
||||
* @param array{currency?: string, address?: array{country_code: string, postal_code?: string}, customer_id?: string, address_id?: string} $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function previewDiscount(Coupon $coupon, array $items, array $context = []): array
|
||||
{
|
||||
$payload = [
|
||||
'items' => $items,
|
||||
'discount_id' => $coupon->paddle_discount_id,
|
||||
];
|
||||
|
||||
if (isset($context['currency'])) {
|
||||
$payload['currency_code'] = Str::upper($context['currency']);
|
||||
}
|
||||
|
||||
if (isset($context['address'])) {
|
||||
$payload['address'] = $context['address'];
|
||||
}
|
||||
|
||||
if (isset($context['customer_id'])) {
|
||||
$payload['customer_id'] = $context['customer_id'];
|
||||
}
|
||||
|
||||
if (isset($context['address_id'])) {
|
||||
$payload['address_id'] = $context['address_id'];
|
||||
}
|
||||
|
||||
$response = $this->client->post('/transactions/preview', $payload);
|
||||
|
||||
return Arr::get($response, 'data', $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function buildDiscountPayload(Coupon $coupon): array
|
||||
{
|
||||
$payload = [
|
||||
'name' => $coupon->name,
|
||||
'code' => $coupon->code,
|
||||
'type' => $this->mapType($coupon->type),
|
||||
'amount' => $this->formatAmount($coupon),
|
||||
'currency_code' => $coupon->currency ?? config('app.currency', 'EUR'),
|
||||
'enabled_for_checkout' => $coupon->enabled_for_checkout,
|
||||
'description' => $coupon->description,
|
||||
'mode' => $coupon->paddle_mode ?? 'standard',
|
||||
'usage_limit' => $coupon->usage_limit,
|
||||
'maximum_recurring_intervals' => null,
|
||||
'recur' => false,
|
||||
'restrict_to' => $this->resolveRestrictions($coupon),
|
||||
'starts_at' => optional($coupon->starts_at)?->toIso8601String(),
|
||||
'expires_at' => optional($coupon->ends_at)?->toIso8601String(),
|
||||
];
|
||||
|
||||
if ($payload['type'] === 'percentage') {
|
||||
unset($payload['currency_code']);
|
||||
}
|
||||
|
||||
return Collection::make($payload)
|
||||
->reject(static fn ($value) => $value === null || $value === '')
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function formatAmount(Coupon $coupon): string
|
||||
{
|
||||
if ($coupon->type === CouponType::PERCENTAGE) {
|
||||
return (string) $coupon->amount;
|
||||
}
|
||||
|
||||
return (string) ((int) round($coupon->amount * 100));
|
||||
}
|
||||
|
||||
protected function mapType(CouponType $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
CouponType::PERCENTAGE => 'percentage',
|
||||
CouponType::FLAT => 'flat',
|
||||
CouponType::FLAT_PER_SEAT => 'flat_per_seat',
|
||||
};
|
||||
}
|
||||
|
||||
protected function resolveRestrictions(Coupon $coupon): ?array
|
||||
{
|
||||
$packages = ($coupon->relationLoaded('packages')
|
||||
? $coupon->packages
|
||||
: $coupon->packages()->get())
|
||||
->whereNotNull('paddle_price_id');
|
||||
|
||||
if ($packages->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prices = $packages->pluck('paddle_price_id')->filter()->values();
|
||||
|
||||
return $prices->isEmpty() ? null : $prices->all();
|
||||
}
|
||||
}
|
||||
@@ -28,17 +28,20 @@ class JoinTokenLayoutRegistry
|
||||
'accent' => '#B85C76',
|
||||
'secondary' => '#E7D6DC',
|
||||
'badge' => '#7A9375',
|
||||
'badge_label' => 'Unsere Gästegalerie',
|
||||
'instructions_heading' => 'So seid ihr dabei',
|
||||
'link_heading' => 'Falls der Scan nicht klappt',
|
||||
'cta_label' => 'Gästegalerie öffnen',
|
||||
'cta_caption' => 'Jetzt Erinnerungen sammeln',
|
||||
'badge_label' => 'Digitale Gästebox',
|
||||
'instructions_heading' => 'So läuft\'s für eure Gäste',
|
||||
'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen',
|
||||
'link_label' => 'fotospiel.app/DEINCODE',
|
||||
'cta_label' => 'Fotos & Grüße teilen',
|
||||
'cta_caption' => 'Sofort starten',
|
||||
'qr' => ['size_px' => 640],
|
||||
'svg' => ['width' => 1240, 'height' => 1754],
|
||||
'instructions' => [
|
||||
'QR-Code scannen und mit eurem Lieblingsnamen anmelden.',
|
||||
'Ein paar Schnappschüsse teilen – gern auch Behind-the-Scenes!',
|
||||
'Likes vergeben und Grüße für das Brautpaar schreiben.',
|
||||
'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
|
||||
'Anzeigenamen wählen – kein Account nötig.',
|
||||
'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
|
||||
'Highlights liken, Kommentare und Grüße dalassen.',
|
||||
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
|
||||
],
|
||||
],
|
||||
'midnight-gala' => [
|
||||
@@ -57,17 +60,20 @@ class JoinTokenLayoutRegistry
|
||||
'accent' => '#F9C74F',
|
||||
'secondary' => '#4E5D8F',
|
||||
'badge' => '#F94144',
|
||||
'badge_label' => 'Team Lounge Access',
|
||||
'instructions_heading' => 'In drei Schritten bereit',
|
||||
'link_heading' => 'Link teilen statt scannen',
|
||||
'cta_label' => 'Jetzt Event-Hub öffnen',
|
||||
'cta_caption' => 'Programm, Uploads & Highlights',
|
||||
'badge_label' => 'Digitale Gästebox',
|
||||
'instructions_heading' => 'So läuft\'s für eure Gäste',
|
||||
'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen',
|
||||
'link_label' => 'fotospiel.app/DEINCODE',
|
||||
'cta_label' => 'Scan & losknipsen',
|
||||
'cta_caption' => 'Keine App nötig',
|
||||
'qr' => ['size_px' => 640],
|
||||
'svg' => ['width' => 1240, 'height' => 1754],
|
||||
'instructions' => [
|
||||
'QR-Code scannen oder Kurzlink eingeben.',
|
||||
'Mit Firmen-E-Mail anmelden und Zugang bestätigen.',
|
||||
'Agenda verfolgen, Fotos teilen und Highlights voten.',
|
||||
'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
|
||||
'Anzeigenamen wählen – kein Account nötig.',
|
||||
'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
|
||||
'Highlights liken, Kommentare und Grüße dalassen.',
|
||||
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
|
||||
],
|
||||
],
|
||||
'garden-brunch' => [
|
||||
@@ -86,17 +92,20 @@ class JoinTokenLayoutRegistry
|
||||
'accent' => '#6BAA75',
|
||||
'secondary' => '#DDE9D8',
|
||||
'badge' => '#F1C376',
|
||||
'badge_label' => 'Brunch Fotostation',
|
||||
'instructions_heading' => 'So funktioniert’s',
|
||||
'link_heading' => 'Alternativ zum Scannen',
|
||||
'cta_label' => 'Gästebuch öffnen',
|
||||
'cta_caption' => 'Eure Grüße festhalten',
|
||||
'badge_label' => 'Digitale Gästebox',
|
||||
'instructions_heading' => 'So läuft\'s für eure Gäste',
|
||||
'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen',
|
||||
'link_label' => 'fotospiel.app/DEINCODE',
|
||||
'cta_label' => 'Jetzt Erinnerungen hochladen',
|
||||
'cta_caption' => 'Los geht’s',
|
||||
'qr' => ['size_px' => 660],
|
||||
'svg' => ['width' => 1240, 'height' => 1754],
|
||||
'instructions' => [
|
||||
'QR-Code scannen und Namen eintragen.',
|
||||
'Lieblingsfoto hochladen oder neue Momente festhalten.',
|
||||
'Aufgaben ausprobieren und anderen ein Herz dalassen.',
|
||||
'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
|
||||
'Anzeigenamen wählen – kein Account nötig.',
|
||||
'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
|
||||
'Highlights liken, Kommentare und Grüße dalassen.',
|
||||
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
|
||||
],
|
||||
],
|
||||
'sparkler-soiree' => [
|
||||
@@ -115,17 +124,20 @@ class JoinTokenLayoutRegistry
|
||||
'accent' => '#F9A826',
|
||||
'secondary' => '#DDB7FF',
|
||||
'badge' => '#FF6F61',
|
||||
'badge_label' => 'Night Shots',
|
||||
'instructions_heading' => 'Step-by-Step',
|
||||
'link_heading' => 'QR funktioniert nicht?',
|
||||
'cta_label' => 'Partyfeed starten',
|
||||
'cta_caption' => 'Momente live teilen',
|
||||
'badge_label' => 'Digitale Gästebox',
|
||||
'instructions_heading' => 'So läuft\'s für eure Gäste',
|
||||
'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen',
|
||||
'link_label' => 'fotospiel.app/DEINCODE',
|
||||
'cta_label' => 'Galerie öffnen',
|
||||
'cta_caption' => 'Challenges spielen',
|
||||
'qr' => ['size_px' => 680],
|
||||
'svg' => ['width' => 1240, 'height' => 1754],
|
||||
'instructions' => [
|
||||
'Code scannen und kurz registrieren.',
|
||||
'Spotlights & Challenges entdecken.',
|
||||
'Fotos hochladen und die besten Shots voten.',
|
||||
'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
|
||||
'Anzeigenamen wählen – kein Account nötig.',
|
||||
'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
|
||||
'Highlights liken, Kommentare und Grüße dalassen.',
|
||||
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
|
||||
],
|
||||
],
|
||||
'confetti-bash' => [
|
||||
@@ -144,17 +156,20 @@ class JoinTokenLayoutRegistry
|
||||
'accent' => '#FF6F61',
|
||||
'secondary' => '#F9D6A5',
|
||||
'badge' => '#4E88FF',
|
||||
'badge_label' => 'Party-Schnappschüsse',
|
||||
'instructions_heading' => 'Leg direkt los',
|
||||
'link_heading' => 'Kurzlink für Gäste',
|
||||
'cta_label' => 'Zur Geburtstagswand',
|
||||
'cta_caption' => 'Fotos & Grüße posten',
|
||||
'badge_label' => 'Digitale Gästebox',
|
||||
'instructions_heading' => 'So läuft\'s für eure Gäste',
|
||||
'link_heading' => 'Kein Scanner? Einfach Kurzlink öffnen',
|
||||
'link_label' => 'fotospiel.app/DEINCODE',
|
||||
'cta_label' => 'Uploads beginnen',
|
||||
'cta_caption' => 'Likes vergeben',
|
||||
'qr' => ['size_px' => 680],
|
||||
'svg' => ['width' => 1240, 'height' => 1754],
|
||||
'instructions' => [
|
||||
'QR-Code scannen und Wunschname auswählen.',
|
||||
'Dein erstes Foto oder Video hochladen.',
|
||||
'Freunde einladen, Likes vergeben und gemeinsam feiern!',
|
||||
'QR-Code scannen oder fotospiel.app/DEINCODE eingeben.',
|
||||
'Anzeigenamen wählen – kein Account nötig.',
|
||||
'Fotos hochladen und Aufgaben erfüllen, so oft ihr wollt.',
|
||||
'Highlights liken, Kommentare und Grüße dalassen.',
|
||||
'Datenschutz ready: anonyme Sessions, keine App-Installation.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
41
app/Support/TenantRequestResolver.php
Normal file
41
app/Support/TenantRequestResolver.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class TenantRequestResolver
|
||||
{
|
||||
public static function resolve(Request $request): Tenant
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
$tenantId = $request->attributes->get('tenant_id')
|
||||
?? $request->attributes->get('current_tenant_id')
|
||||
?? $request->user()?->tenant_id;
|
||||
|
||||
if ($tenantId) {
|
||||
$tenant = Tenant::query()->find($tenantId);
|
||||
|
||||
if ($tenant) {
|
||||
$request->attributes->set('tenant', $tenant);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
throw new HttpResponseException(ApiError::response(
|
||||
'tenant_context_missing',
|
||||
'Tenant context missing',
|
||||
'Unable to determine tenant for the current request.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user