geschenkgutscheine implementiert ("Paket verschenken"). Neuer Upload-Provider: Sparkbooth.
This commit is contained in:
109
app/Filament/Resources/GiftVoucherResource.php
Normal file
109
app/Filament/Resources/GiftVoucherResource.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\GiftVoucherResource\Pages;
|
||||
use App\Models\GiftVoucher;
|
||||
use App\Services\GiftVouchers\GiftVoucherService;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
|
||||
class GiftVoucherResource extends Resource
|
||||
{
|
||||
protected static ?string $model = GiftVoucher::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-gift';
|
||||
|
||||
protected static ?int $navigationSort = 12;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.billing');
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('code')
|
||||
->label('Code')
|
||||
->searchable()
|
||||
->copyable(),
|
||||
TextColumn::make('amount')
|
||||
->label('Betrag')
|
||||
->money(fn (GiftVoucher $record) => $record->currency ?? 'EUR'),
|
||||
BadgeColumn::make('status')
|
||||
->label('Status')
|
||||
->colors([
|
||||
'success' => GiftVoucher::STATUS_REDEEMED,
|
||||
'warning' => GiftVoucher::STATUS_ISSUED,
|
||||
'danger' => [GiftVoucher::STATUS_REFUNDED, GiftVoucher::STATUS_EXPIRED],
|
||||
]),
|
||||
TextColumn::make('purchaser_email')
|
||||
->label('Käufer')
|
||||
->toggleable()
|
||||
->searchable(),
|
||||
TextColumn::make('recipient_email')
|
||||
->label('Empfänger')
|
||||
->toggleable()
|
||||
->searchable(),
|
||||
TextColumn::make('paddle_transaction_id')
|
||||
->label('Paddle Tx')
|
||||
->toggleable()
|
||||
->copyable()
|
||||
->wrap(),
|
||||
TextColumn::make('expires_at')
|
||||
->label('Gültig bis')
|
||||
->dateTime(),
|
||||
TextColumn::make('redeemed_at')
|
||||
->label('Eingelöst am')
|
||||
->dateTime(),
|
||||
TextColumn::make('refunded_at')
|
||||
->label('Erstattet am')
|
||||
->dateTime(),
|
||||
TextColumn::make('created_at')
|
||||
->label('Erstellt')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->options([
|
||||
GiftVoucher::STATUS_ISSUED => 'Ausgestellt',
|
||||
GiftVoucher::STATUS_REDEEMED => 'Eingelöst',
|
||||
GiftVoucher::STATUS_REFUNDED => 'Erstattet',
|
||||
GiftVoucher::STATUS_EXPIRED => 'Abgelaufen',
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('refund')
|
||||
->label('Refund')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (GiftVoucher $record): bool => $record->canBeRefunded())
|
||||
->action(function (GiftVoucher $record, GiftVoucherService $service): void {
|
||||
$service->refund($record, 'customer_request');
|
||||
})
|
||||
->successNotificationTitle('Gutschein erstattet'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListGiftVouchers::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\GiftVoucherResource\Pages;
|
||||
|
||||
use App\Filament\Resources\GiftVoucherResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListGiftVouchers extends ListRecords
|
||||
{
|
||||
protected static string $resource = GiftVoucherResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -2377,7 +2377,7 @@ class EventPublicController extends BaseController
|
||||
|
||||
// MyPhotos filter
|
||||
if ($filter === 'photobooth') {
|
||||
$query->where('photos.ingest_source', Photo::SOURCE_PHOTOBOOTH);
|
||||
$query->whereIn('photos.ingest_source', [Photo::SOURCE_PHOTOBOOTH, Photo::SOURCE_SPARKBOOTH]);
|
||||
} elseif ($filter === 'myphotos' && $deviceId !== 'anon') {
|
||||
$query->where('guest_name', $deviceId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GiftVoucherCheckoutController extends Controller
|
||||
{
|
||||
public function __construct(private readonly GiftVoucherCheckoutService $checkout) {}
|
||||
|
||||
public function tiers(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => $this->checkout->tiers(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $this->validate($request, [
|
||||
'tier_key' => ['required', 'string'],
|
||||
'purchaser_email' => ['required', 'email:rfc,dns'],
|
||||
'recipient_email' => ['nullable', 'email:rfc,dns'],
|
||||
'recipient_name' => ['nullable', 'string', 'max:191'],
|
||||
'message' => ['nullable', 'string', 'max:500'],
|
||||
'success_url' => ['nullable', 'url'],
|
||||
'return_url' => ['nullable', 'url'],
|
||||
]);
|
||||
|
||||
$checkout = $this->checkout->create($data);
|
||||
|
||||
if (! $checkout['checkout_url']) {
|
||||
throw ValidationException::withMessages([
|
||||
'tier_key' => __('Unable to create Paddle checkout.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($checkout);
|
||||
}
|
||||
}
|
||||
139
app/Http/Controllers/Api/SparkboothUploadController.php
Normal file
139
app/Http/Controllers/Api/SparkboothUploadController.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Event;
|
||||
use App\Services\Photobooth\Exceptions\SparkboothUploadException;
|
||||
use App\Services\Photobooth\SparkboothUploadService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SparkboothUploadController extends Controller
|
||||
{
|
||||
public function __construct(private readonly SparkboothUploadService $service) {}
|
||||
|
||||
public function store(Request $request): Response
|
||||
{
|
||||
$media = $this->resolveMedia($request);
|
||||
|
||||
if (! $media) {
|
||||
return $this->respond(null, false, 'Media is required', null, 400, $request);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->service->handleUpload(
|
||||
$media,
|
||||
$request->input('username'),
|
||||
$request->input('password')
|
||||
);
|
||||
|
||||
/** @var Event $event */
|
||||
$event = $result['event'];
|
||||
|
||||
return $this->respond($event, true, null, null, 200, $request);
|
||||
} catch (SparkboothUploadException $exception) {
|
||||
return $this->respond(null, false, $exception->getMessage(), null, $exception->statusCode ?? 400, $request);
|
||||
} catch (\Throwable) {
|
||||
return $this->respond(null, false, 'Upload failed, please retry.', null, 500, $request);
|
||||
}
|
||||
}
|
||||
|
||||
protected function respond(?Event $event, bool $ok, ?string $message, ?string $url, int $status, Request $request): Response
|
||||
{
|
||||
$format = $this->resolveFormat($event, $request);
|
||||
|
||||
if ($format === 'xml') {
|
||||
$payload = $ok
|
||||
? $this->buildSuccessXml($url)
|
||||
: $this->buildFailureXml($message);
|
||||
|
||||
return response($payload, $status, ['Content-Type' => 'application/xml']);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => $ok,
|
||||
'error' => $ok ? null : $message,
|
||||
'url' => $url,
|
||||
], $status);
|
||||
}
|
||||
|
||||
protected function resolveFormat(?Event $event, Request $request): string
|
||||
{
|
||||
$preferred = $request->input('format');
|
||||
|
||||
if ($preferred && in_array($preferred, ['json', 'xml'], true)) {
|
||||
return $preferred;
|
||||
}
|
||||
|
||||
$configured = $event?->photobooth_metadata['sparkbooth_response_format'] ?? null;
|
||||
|
||||
if ($configured && in_array($configured, ['json', 'xml'], true)) {
|
||||
return $configured;
|
||||
}
|
||||
|
||||
return config('photobooth.sparkbooth.response_format', 'json') === 'xml' ? 'xml' : 'json';
|
||||
}
|
||||
|
||||
protected function buildSuccessXml(?string $url): string
|
||||
{
|
||||
$urlAttribute = $url ? ' url="'.htmlspecialchars($url, ENT_QUOTES).'"' : '';
|
||||
|
||||
return sprintf('<?xml version="1.0" encoding="UTF-8"?>'."\n".'<rsp status="ok"%s />', $urlAttribute);
|
||||
}
|
||||
|
||||
protected function buildFailureXml(?string $message): string
|
||||
{
|
||||
$escaped = htmlspecialchars($message ?? 'Upload failed', ENT_QUOTES);
|
||||
|
||||
return sprintf(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'."\n".'<rsp status="fail"><err msg="%s" /></rsp>',
|
||||
$escaped
|
||||
);
|
||||
}
|
||||
|
||||
protected function resolveMedia(Request $request): ?UploadedFile
|
||||
{
|
||||
$file = $request->file('media');
|
||||
|
||||
if ($file instanceof UploadedFile) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
$raw = $request->input('media');
|
||||
|
||||
if (is_string($raw) && $raw !== '') {
|
||||
return $this->createUploadedFileFromBase64($raw);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function createUploadedFileFromBase64(string $raw): ?UploadedFile
|
||||
{
|
||||
$payload = $raw;
|
||||
|
||||
if (Str::startsWith($raw, 'data:')) {
|
||||
$segments = explode(',', $raw, 2);
|
||||
$payload = $segments[1] ?? '';
|
||||
}
|
||||
|
||||
$decoded = base64_decode($payload, true);
|
||||
|
||||
if ($decoded === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tmpPath = tempnam(sys_get_temp_dir(), 'sparkbooth-');
|
||||
|
||||
if (! $tmpPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
file_put_contents($tmpPath, $decoded);
|
||||
|
||||
return new UploadedFile($tmpPath, 'upload.jpg', null, null, true);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,10 @@ class PhotoboothController extends Controller
|
||||
$this->assertEventBelongsToTenant($request, $event);
|
||||
|
||||
$event->loadMissing('tenant');
|
||||
$updated = $this->provisioner->enable($event);
|
||||
$mode = $this->resolveMode($request);
|
||||
$updated = $mode === 'sparkbooth'
|
||||
? $this->provisioner->enableSparkbooth($event)
|
||||
: $this->provisioner->enable($event);
|
||||
|
||||
return response()->json([
|
||||
'message' => __('Photobooth-Zugang aktiviert.'),
|
||||
@@ -39,7 +42,10 @@ class PhotoboothController extends Controller
|
||||
$this->assertEventBelongsToTenant($request, $event);
|
||||
|
||||
$event->loadMissing('tenant');
|
||||
$updated = $this->provisioner->rotate($event);
|
||||
$mode = $this->resolveMode($request);
|
||||
$updated = $mode === 'sparkbooth'
|
||||
? $this->provisioner->rotateSparkbooth($event)
|
||||
: $this->provisioner->rotate($event);
|
||||
|
||||
return response()->json([
|
||||
'message' => __('Zugangsdaten neu generiert.'),
|
||||
@@ -52,7 +58,10 @@ class PhotoboothController extends Controller
|
||||
$this->assertEventBelongsToTenant($request, $event);
|
||||
|
||||
$event->loadMissing('tenant');
|
||||
$updated = $this->provisioner->disable($event);
|
||||
$mode = $this->resolveMode($request);
|
||||
$updated = $mode === 'sparkbooth'
|
||||
? $this->provisioner->disableSparkbooth($event)
|
||||
: $this->provisioner->disable($event);
|
||||
|
||||
return response()->json([
|
||||
'message' => __('Photobooth-Zugang deaktiviert.'),
|
||||
@@ -76,4 +85,11 @@ class PhotoboothController extends Controller
|
||||
abort(403, 'Event gehört nicht zu diesem Tenant.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function resolveMode(Request $request): string
|
||||
{
|
||||
$mode = strtolower((string) $request->input('mode', $request->input('type', 'ftp')));
|
||||
|
||||
return in_array($mode, ['sparkbooth', 'ftp'], true) ? $mode : 'ftp';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\PackagePurchase;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Coupons\CouponService;
|
||||
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -39,6 +40,7 @@ class MarketingController extends Controller
|
||||
private readonly CheckoutSessionService $checkoutSessions,
|
||||
private readonly PaddleCheckoutService $paddleCheckout,
|
||||
private readonly CouponService $coupons,
|
||||
private readonly GiftVoucherCheckoutService $giftVouchers,
|
||||
) {}
|
||||
|
||||
public function index()
|
||||
@@ -124,6 +126,15 @@ class MarketingController extends Controller
|
||||
return Inertia::render('marketing/Kontakt');
|
||||
}
|
||||
|
||||
public function giftVouchers()
|
||||
{
|
||||
$tiers = $this->giftVouchers->tiers();
|
||||
|
||||
return Inertia::render('marketing/GiftVoucher', [
|
||||
'tiers' => $tiers,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle package purchase flow.
|
||||
*/
|
||||
|
||||
@@ -20,22 +20,50 @@ class PhotoboothStatusResource extends JsonResource
|
||||
/** @var PhotoboothSetting $settings */
|
||||
$settings = $payload['settings'];
|
||||
|
||||
$password = $event->getAttribute('plain_photobooth_password') ?? $event->photobooth_password;
|
||||
$mode = $event->photobooth_mode ?? 'ftp';
|
||||
$isSparkbooth = $mode === 'sparkbooth';
|
||||
|
||||
$password = $isSparkbooth
|
||||
? $event->getAttribute('plain_sparkbooth_password') ?? $event->sparkbooth_password
|
||||
: $event->getAttribute('plain_photobooth_password') ?? $event->photobooth_password;
|
||||
|
||||
$activeUsername = $isSparkbooth ? $event->sparkbooth_username : $event->photobooth_username;
|
||||
$activeStatus = $isSparkbooth ? $event->sparkbooth_status : $event->photobooth_status;
|
||||
$activeExpires = $isSparkbooth ? $event->sparkbooth_expires_at : $event->photobooth_expires_at;
|
||||
|
||||
$sparkMetrics = [
|
||||
'last_upload_at' => optional($event->sparkbooth_last_upload_at)->toIso8601String(),
|
||||
'uploads_24h' => (int) ($event->sparkbooth_uploads_last_24h ?? 0),
|
||||
'uploads_total' => (int) ($event->sparkbooth_uploads_total ?? 0),
|
||||
];
|
||||
|
||||
return [
|
||||
'mode' => $mode,
|
||||
'enabled' => (bool) $event->photobooth_enabled,
|
||||
'status' => $event->photobooth_status,
|
||||
'username' => $event->photobooth_username,
|
||||
'status' => $activeStatus,
|
||||
'username' => $activeUsername,
|
||||
'password' => $password,
|
||||
'path' => $event->photobooth_path,
|
||||
'ftp_url' => $this->buildFtpUrl($event, $settings, $password),
|
||||
'expires_at' => optional($event->photobooth_expires_at)->toIso8601String(),
|
||||
'ftp_url' => $isSparkbooth ? null : $this->buildFtpUrl($event, $settings, $password),
|
||||
'upload_url' => $isSparkbooth ? route('api.v1.photobooth.sparkbooth.upload') : null,
|
||||
'expires_at' => optional($activeExpires)->toIso8601String(),
|
||||
'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute,
|
||||
'ftp' => [
|
||||
'host' => config('photobooth.ftp.host'),
|
||||
'port' => $settings->ftp_port,
|
||||
'require_ftps' => (bool) $settings->require_ftps,
|
||||
],
|
||||
'metrics' => $isSparkbooth ? $sparkMetrics : null,
|
||||
'sparkbooth' => [
|
||||
'enabled' => $mode === 'sparkbooth' && $event->photobooth_enabled,
|
||||
'status' => $event->sparkbooth_status,
|
||||
'username' => $event->sparkbooth_username,
|
||||
'password' => $event->getAttribute('plain_sparkbooth_password') ?? $event->sparkbooth_password,
|
||||
'expires_at' => optional($event->sparkbooth_expires_at)->toIso8601String(),
|
||||
'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
|
||||
'response_format' => $event->photobooth_metadata['sparkbooth_response_format'] ?? config('photobooth.sparkbooth.response_format', 'json'),
|
||||
'metrics' => $sparkMetrics,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -97,6 +98,11 @@ class Coupon extends Model
|
||||
return $this->hasMany(CouponRedemption::class);
|
||||
}
|
||||
|
||||
public function giftVoucher(): HasOne
|
||||
{
|
||||
return $this->hasOne(GiftVoucher::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', CouponStatus::ACTIVE)
|
||||
|
||||
@@ -25,12 +25,16 @@ class Event extends Model
|
||||
'name' => 'array',
|
||||
'description' => 'array',
|
||||
'photobooth_enabled' => 'boolean',
|
||||
'photobooth_mode' => 'string',
|
||||
'photobooth_expires_at' => 'datetime',
|
||||
'photobooth_metadata' => 'array',
|
||||
'sparkbooth_expires_at' => 'datetime',
|
||||
'sparkbooth_last_upload_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'photobooth_password_encrypted',
|
||||
'sparkbooth_password_encrypted',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
@@ -196,4 +200,26 @@ class Event extends Model
|
||||
? Crypt::encryptString($value)
|
||||
: null;
|
||||
}
|
||||
|
||||
public function getSparkboothPasswordAttribute(): ?string
|
||||
{
|
||||
$encrypted = $this->attributes['sparkbooth_password_encrypted'] ?? null;
|
||||
|
||||
if (! $encrypted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Crypt::decryptString($encrypted);
|
||||
} catch (DecryptException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function setSparkboothPasswordAttribute(?string $value): void
|
||||
{
|
||||
$this->attributes['sparkbooth_password_encrypted'] = $value
|
||||
? Crypt::encryptString($value)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
95
app/Models/GiftVoucher.php
Normal file
95
app/Models/GiftVoucher.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GiftVoucher extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\GiftVoucherFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SoftDeletes;
|
||||
|
||||
public const STATUS_ISSUED = 'issued';
|
||||
|
||||
public const STATUS_REDEEMED = 'redeemed';
|
||||
|
||||
public const STATUS_REFUNDED = 'refunded';
|
||||
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'amount',
|
||||
'currency',
|
||||
'status',
|
||||
'purchaser_email',
|
||||
'recipient_email',
|
||||
'recipient_name',
|
||||
'message',
|
||||
'paddle_transaction_id',
|
||||
'paddle_checkout_id',
|
||||
'paddle_price_id',
|
||||
'coupon_id',
|
||||
'expires_at',
|
||||
'redeemed_at',
|
||||
'refunded_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'decimal:2',
|
||||
'expires_at' => 'datetime',
|
||||
'redeemed_at' => 'datetime',
|
||||
'refunded_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $voucher): void {
|
||||
if ($voucher->code) {
|
||||
$voucher->code = Str::upper($voucher->code);
|
||||
}
|
||||
|
||||
if ($voucher->currency) {
|
||||
$voucher->currency = Str::upper($voucher->currency);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function coupon(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Coupon::class);
|
||||
}
|
||||
|
||||
public function isRedeemed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_REDEEMED;
|
||||
}
|
||||
|
||||
public function isRefunded(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_REFUNDED;
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_EXPIRED || ($this->expires_at && $this->expires_at->isPast());
|
||||
}
|
||||
|
||||
public function canBeRedeemed(): bool
|
||||
{
|
||||
return ! $this->isRedeemed() && ! $this->isRefunded() && ! $this->isExpired();
|
||||
}
|
||||
|
||||
public function canBeRefunded(): bool
|
||||
{
|
||||
return ! $this->isRedeemed() && ! $this->isRefunded();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ class Photo extends Model
|
||||
|
||||
public const SOURCE_PHOTOBOOTH = 'photobooth';
|
||||
|
||||
public const SOURCE_SPARKBOOTH = 'sparkbooth';
|
||||
|
||||
public const SOURCE_UNKNOWN = 'unknown';
|
||||
|
||||
protected static ?array $columnCache = null;
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Coupons\CouponRedemptionService;
|
||||
use App\Services\GiftVouchers\GiftVoucherService;
|
||||
use App\Services\Paddle\PaddleSubscriptionService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -21,6 +22,7 @@ class CheckoutWebhookService
|
||||
private readonly CheckoutAssignmentService $assignment,
|
||||
private readonly PaddleSubscriptionService $paddleSubscriptions,
|
||||
private readonly CouponRedemptionService $couponRedemptions,
|
||||
private readonly GiftVoucherService $giftVouchers,
|
||||
) {}
|
||||
|
||||
public function handleStripeEvent(array $event): bool
|
||||
@@ -93,6 +95,16 @@ class CheckoutWebhookService
|
||||
return $this->handlePaddleSubscriptionEvent($eventType, $data);
|
||||
}
|
||||
|
||||
if ($this->isGiftVoucherEvent($data)) {
|
||||
if ($eventType === 'transaction.completed') {
|
||||
$this->giftVouchers->issueFromPaddle($data);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($eventType, ['transaction.processing', 'transaction.created', 'transaction.failed', 'transaction.cancelled'], true);
|
||||
}
|
||||
|
||||
$session = $this->locatePaddleSession($data);
|
||||
|
||||
if (! $session) {
|
||||
@@ -429,6 +441,25 @@ class CheckoutWebhookService
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function isGiftVoucherEvent(array $data): bool
|
||||
{
|
||||
$metadata = $data['metadata'] ?? [];
|
||||
|
||||
$type = is_array($metadata) ? ($metadata['type'] ?? $metadata['kind'] ?? $metadata['category'] ?? null) : null;
|
||||
|
||||
if ($type && in_array(strtolower($type), ['gift_card', 'gift_voucher'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$priceId = $data['price_id'] ?? Arr::get($metadata, 'paddle_price_id');
|
||||
$tiers = collect(config('gift-vouchers.tiers', []))
|
||||
->pluck('paddle_price_id')
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
return $priceId && in_array($priceId, $tiers, true);
|
||||
}
|
||||
|
||||
protected function locatePaddleSession(array $data): ?CheckoutSession
|
||||
{
|
||||
$metadata = $data['metadata'] ?? [];
|
||||
|
||||
@@ -4,10 +4,13 @@ namespace App\Services\Coupons;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\CouponRedemption;
|
||||
use App\Services\GiftVouchers\GiftVoucherService;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class CouponRedemptionService
|
||||
{
|
||||
public function __construct(private readonly GiftVoucherService $giftVouchers) {}
|
||||
|
||||
public function recordSuccess(CheckoutSession $session, array $payload = []): void
|
||||
{
|
||||
if (! $session->coupon_id) {
|
||||
@@ -41,6 +44,8 @@ class CouponRedemptionService
|
||||
);
|
||||
|
||||
$session->coupon?->increment('redemptions_count');
|
||||
|
||||
$this->giftVouchers->markRedeemed($session->coupon, $transactionId);
|
||||
}
|
||||
|
||||
public function recordFailure(CheckoutSession $session, string $reason): void
|
||||
|
||||
100
app/Services/GiftVouchers/GiftVoucherCheckoutService.php
Normal file
100
app/Services/GiftVouchers/GiftVoucherCheckoutService.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\GiftVouchers;
|
||||
|
||||
use App\Services\Paddle\PaddleClient;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GiftVoucherCheckoutService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,label:string,amount:float,currency:string,paddle_price_id?:string|null,can_checkout:bool}>
|
||||
*/
|
||||
public function tiers(): array
|
||||
{
|
||||
return collect(config('gift-vouchers.tiers', []))
|
||||
->map(function (array $tier): array {
|
||||
$currency = Str::upper($tier['currency'] ?? 'EUR');
|
||||
$priceId = $tier['paddle_price_id'] ?? null;
|
||||
|
||||
return [
|
||||
'key' => $tier['key'],
|
||||
'label' => $tier['label'],
|
||||
'amount' => (float) $tier['amount'],
|
||||
'currency' => $currency,
|
||||
'paddle_price_id' => $priceId,
|
||||
'can_checkout' => ! empty($priceId),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{tier_key:string,purchaser_email:string,recipient_email?:string|null,recipient_name?:string|null,message?:string|null,success_url?:string|null,return_url?:string|null} $data
|
||||
* @return array{checkout_url:?string,expires_at:?string,id:?string}
|
||||
*/
|
||||
public function create(array $data): array
|
||||
{
|
||||
$tier = $this->findTier($data['tier_key']);
|
||||
|
||||
if (! $tier || empty($tier['paddle_price_id'])) {
|
||||
throw ValidationException::withMessages([
|
||||
'tier_key' => __('Gift voucher is not available right now.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'items' => [
|
||||
[
|
||||
'price_id' => $tier['paddle_price_id'],
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
'customer_email' => $data['purchaser_email'],
|
||||
'metadata' => array_filter([
|
||||
'type' => 'gift_voucher',
|
||||
'tier_key' => $tier['key'],
|
||||
'purchaser_email' => $data['purchaser_email'],
|
||||
'recipient_email' => $data['recipient_email'] ?? null,
|
||||
'recipient_name' => $data['recipient_name'] ?? null,
|
||||
'message' => $data['message'] ?? null,
|
||||
'app_locale' => App::getLocale(),
|
||||
]),
|
||||
'success_url' => $data['success_url'] ?? route('marketing.success', ['locale' => App::getLocale(), 'type' => 'gift']),
|
||||
'cancel_url' => $data['return_url'] ?? route('packages', ['locale' => App::getLocale()]),
|
||||
];
|
||||
|
||||
$response = $this->client->post('/checkout/links', $payload);
|
||||
|
||||
return [
|
||||
'checkout_url' => Arr::get($response, 'data.url') ?? Arr::get($response, 'url'),
|
||||
'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'),
|
||||
'id' => Arr::get($response, 'data.id') ?? Arr::get($response, 'id'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
protected function findTier(string $key): ?array
|
||||
{
|
||||
$tiers = collect(config('gift-vouchers.tiers', []))
|
||||
->keyBy('key');
|
||||
|
||||
$tier = $tiers->get($key);
|
||||
|
||||
if (! $tier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tier['currency'] = Str::upper($tier['currency'] ?? 'EUR');
|
||||
|
||||
return $tier;
|
||||
}
|
||||
}
|
||||
215
app/Services/GiftVouchers/GiftVoucherService.php
Normal file
215
app/Services/GiftVouchers/GiftVoucherService.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\GiftVouchers;
|
||||
|
||||
use App\Enums\CouponStatus;
|
||||
use App\Enums\CouponType;
|
||||
use App\Jobs\SyncCouponToPaddle;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\GiftVoucher;
|
||||
use App\Models\Package;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GiftVoucherService
|
||||
{
|
||||
public function __construct(private readonly PaddleTransactionService $transactions) {}
|
||||
|
||||
/**
|
||||
* Create a voucher from a Paddle transaction payload.
|
||||
*/
|
||||
public function issueFromPaddle(array $payload): GiftVoucher
|
||||
{
|
||||
$metadata = $payload['metadata'] ?? [];
|
||||
$priceId = $this->resolvePriceId($payload);
|
||||
$amount = $this->resolveAmount($payload);
|
||||
$currency = Str::upper($this->resolveCurrency($payload));
|
||||
|
||||
$expiresAt = now()->addYears((int) config('gift-vouchers.default_valid_years', 5));
|
||||
|
||||
$voucher = GiftVoucher::query()->updateOrCreate(
|
||||
[
|
||||
'paddle_transaction_id' => $payload['id'] ?? null,
|
||||
],
|
||||
[
|
||||
'code' => $metadata['gift_code'] ?? $this->generateCode(),
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'status' => GiftVoucher::STATUS_ISSUED,
|
||||
'purchaser_email' => $metadata['purchaser_email'] ?? Arr::get($payload, 'customer.email'),
|
||||
'recipient_email' => $metadata['recipient_email'] ?? null,
|
||||
'recipient_name' => $metadata['recipient_name'] ?? null,
|
||||
'message' => $metadata['message'] ?? null,
|
||||
'paddle_checkout_id' => $payload['checkout_id'] ?? Arr::get($payload, 'details.checkout_id'),
|
||||
'paddle_price_id' => $priceId,
|
||||
'metadata' => $metadata,
|
||||
'expires_at' => $expiresAt,
|
||||
'refunded_at' => null,
|
||||
'redeemed_at' => null,
|
||||
]
|
||||
);
|
||||
|
||||
if (! $voucher->coupon_id) {
|
||||
$coupon = $this->createCouponForVoucher($voucher);
|
||||
$voucher->forceFill(['coupon_id' => $coupon->id])->save();
|
||||
SyncCouponToPaddle::dispatch($coupon);
|
||||
}
|
||||
|
||||
return $voucher;
|
||||
}
|
||||
|
||||
public function markRedeemed(?Coupon $coupon, ?string $transactionId = null): void
|
||||
{
|
||||
if (! $coupon?->giftVoucher) {
|
||||
return;
|
||||
}
|
||||
|
||||
$voucher = $coupon->giftVoucher;
|
||||
|
||||
if ($voucher->isRedeemed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$voucher->forceFill([
|
||||
'status' => GiftVoucher::STATUS_REDEEMED,
|
||||
'redeemed_at' => now(),
|
||||
'metadata' => array_merge($voucher->metadata ?? [], array_filter([
|
||||
'redeemed_transaction_id' => $transactionId,
|
||||
])),
|
||||
])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function refund(GiftVoucher $voucher, ?string $reason = null): array
|
||||
{
|
||||
if (! $voucher->canBeRefunded()) {
|
||||
throw ValidationException::withMessages([
|
||||
'voucher' => __('Voucher cannot be refunded after redemption or refund.'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $voucher->paddle_transaction_id) {
|
||||
throw ValidationException::withMessages([
|
||||
'voucher' => __('Missing Paddle transaction for refund.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $this->transactions->refund($voucher->paddle_transaction_id, array_filter([
|
||||
'reason' => $reason,
|
||||
]));
|
||||
|
||||
$voucher->forceFill([
|
||||
'status' => GiftVoucher::STATUS_REFUNDED,
|
||||
'refunded_at' => now(),
|
||||
])->save();
|
||||
|
||||
if ($voucher->coupon) {
|
||||
$voucher->coupon->forceFill([
|
||||
'status' => CouponStatus::ARCHIVED,
|
||||
'enabled_for_checkout' => false,
|
||||
])->save();
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function createCouponForVoucher(GiftVoucher $voucher): Coupon
|
||||
{
|
||||
$packages = $this->eligiblePackages();
|
||||
|
||||
$coupon = Coupon::create([
|
||||
'name' => 'Gutschein '.$voucher->code,
|
||||
'code' => $voucher->code,
|
||||
'type' => CouponType::FLAT,
|
||||
'amount' => $voucher->amount,
|
||||
'currency' => $voucher->currency,
|
||||
'status' => CouponStatus::ACTIVE,
|
||||
'enabled_for_checkout' => true,
|
||||
'is_stackable' => false,
|
||||
'usage_limit' => 1,
|
||||
'per_customer_limit' => 1,
|
||||
'auto_apply' => false,
|
||||
'description' => 'Geschenkgutschein '.number_format((float) $voucher->amount, 2).' '.$voucher->currency.' für Endkunden-Pakete.',
|
||||
'starts_at' => now(),
|
||||
'ends_at' => $voucher->expires_at,
|
||||
]);
|
||||
|
||||
if ($packages->isNotEmpty()) {
|
||||
$coupon->packages()->sync($packages->pluck('id'));
|
||||
}
|
||||
|
||||
return $coupon;
|
||||
}
|
||||
|
||||
protected function eligiblePackages(): Collection
|
||||
{
|
||||
$types = (array) config('gift-vouchers.package_types', ['endcustomer']);
|
||||
|
||||
return Package::query()
|
||||
->whereIn('type', $types)
|
||||
->whereNotNull('paddle_price_id')
|
||||
->get(['id']);
|
||||
}
|
||||
|
||||
protected function resolvePriceId(array $payload): ?string
|
||||
{
|
||||
$metadata = $payload['metadata'] ?? [];
|
||||
|
||||
if (is_array($metadata) && ! empty($metadata['paddle_price_id'])) {
|
||||
return $metadata['paddle_price_id'];
|
||||
}
|
||||
|
||||
$items = Arr::get($payload, 'items', Arr::get($payload, 'details.items', []));
|
||||
if (is_array($items) && isset($items[0]['price_id'])) {
|
||||
return $items[0]['price_id'];
|
||||
}
|
||||
|
||||
return $payload['price_id'] ?? null;
|
||||
}
|
||||
|
||||
protected function resolveAmount(array $payload): float
|
||||
{
|
||||
$tiers = Collection::make(config('gift-vouchers.tiers', []))
|
||||
->keyBy(fn ($tier) => $tier['paddle_price_id'] ?? null);
|
||||
|
||||
$priceId = $this->resolvePriceId($payload);
|
||||
if ($priceId && $tiers->has($priceId)) {
|
||||
return (float) $tiers->get($priceId)['amount'];
|
||||
}
|
||||
|
||||
$amount = Arr::get($payload, 'totals.grand_total.amount')
|
||||
?? Arr::get($payload, 'totals.grand_total')
|
||||
?? Arr::get($payload, 'details.totals.grand_total.amount')
|
||||
?? Arr::get($payload, 'details.totals.grand_total')
|
||||
?? Arr::get($payload, 'amount');
|
||||
|
||||
if (is_numeric($amount)) {
|
||||
$value = (float) $amount;
|
||||
|
||||
return $value >= 100 ? round($value / 100, 2) : round($value, 2);
|
||||
}
|
||||
|
||||
Log::warning('[GiftVoucher] Unable to resolve amount, defaulting to 0', ['payload' => $payload]);
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
protected function resolveCurrency(array $payload): string
|
||||
{
|
||||
return $payload['currency_code']
|
||||
?? Arr::get($payload, 'details.totals.currency_code')
|
||||
?? Arr::get($payload, 'currency')
|
||||
?? 'EUR';
|
||||
}
|
||||
|
||||
protected function generateCode(): string
|
||||
{
|
||||
return 'GIFT-'.Str::upper(Str::random(8));
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ class PaddleCatalogService
|
||||
*/
|
||||
public function createPrice(Package $package, string $productId, array $overrides = []): array
|
||||
{
|
||||
$payload = $this->buildPricePayload($package, $productId, $overrides);
|
||||
$payload = $this->buildPricePayload($package, $productId, $overrides, includeProduct: true);
|
||||
|
||||
return $this->extractEntity($this->client->post('/prices', $payload));
|
||||
}
|
||||
@@ -61,7 +61,12 @@ class PaddleCatalogService
|
||||
*/
|
||||
public function updatePrice(string $priceId, Package $package, array $overrides = []): array
|
||||
{
|
||||
$payload = $this->buildPricePayload($package, $overrides['product_id'] ?? $package->paddle_product_id, $overrides);
|
||||
$payload = $this->buildPricePayload(
|
||||
$package,
|
||||
$overrides['product_id'] ?? $package->paddle_product_id,
|
||||
$overrides,
|
||||
includeProduct: false
|
||||
);
|
||||
|
||||
return $this->extractEntity($this->client->patch("/prices/{$priceId}", $payload));
|
||||
}
|
||||
@@ -85,19 +90,24 @@ class PaddleCatalogService
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildPricePayload(Package $package, string $productId, array $overrides = []): array
|
||||
public function buildPricePayload(Package $package, string $productId, array $overrides = [], bool $includeProduct = true): array
|
||||
{
|
||||
$unitPrice = $overrides['unit_price'] ?? [
|
||||
'amount' => (string) $this->priceToMinorUnits($package->price),
|
||||
'currency_code' => Str::upper((string) ($package->currency ?? 'EUR')),
|
||||
];
|
||||
|
||||
$payload = array_merge([
|
||||
'product_id' => $productId,
|
||||
$base = [
|
||||
'description' => $this->resolvePriceDescription($package, $overrides),
|
||||
'unit_price' => $unitPrice,
|
||||
'custom_data' => $this->buildCustomData($package, $overrides['custom_data'] ?? []),
|
||||
], Arr::except($overrides, ['unit_price', 'description', 'custom_data']));
|
||||
];
|
||||
|
||||
if ($includeProduct) {
|
||||
$base['product_id'] = $productId;
|
||||
}
|
||||
|
||||
$payload = array_merge($base, Arr::except($overrides, ['unit_price', 'description', 'custom_data', 'product_id']));
|
||||
|
||||
return $this->cleanPayload($payload);
|
||||
}
|
||||
|
||||
92
app/Services/Paddle/PaddleGiftVoucherCatalogService.php
Normal file
92
app/Services/Paddle/PaddleGiftVoucherCatalogService.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PaddleGiftVoucherCatalogService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
|
||||
/**
|
||||
* @param array{key:string,label:string,amount:float,currency?:string,paddle_product_id?:string|null,paddle_price_id?:string|null} $tier
|
||||
* @return array{product_id:string,price_id:string}
|
||||
*/
|
||||
public function ensureTier(array $tier): array
|
||||
{
|
||||
$product = $tier['paddle_product_id'] ?? null;
|
||||
$price = $tier['paddle_price_id'] ?? null;
|
||||
|
||||
if (! $product) {
|
||||
$product = $this->createProduct($tier)['id'];
|
||||
}
|
||||
|
||||
if (! $price) {
|
||||
$price = $this->createPrice($tier, $product)['id'];
|
||||
}
|
||||
|
||||
return [
|
||||
'product_id' => $product,
|
||||
'price_id' => $price,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{key:string,label:string,amount:float,currency?:string} $tier
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createProduct(array $tier): array
|
||||
{
|
||||
$payload = [
|
||||
'name' => $tier['label'],
|
||||
'description' => sprintf('Geschenkgutschein im Wert von %.2f %s für Fotospiel Pakete.', $tier['amount'], $this->currency($tier)),
|
||||
'type' => 'standard',
|
||||
'tax_category' => 'standard',
|
||||
'custom_data' => [
|
||||
'kind' => 'gift_voucher',
|
||||
'tier_key' => $tier['key'],
|
||||
'amount' => $tier['amount'],
|
||||
'currency' => $this->currency($tier),
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->client->post('/products', $payload);
|
||||
|
||||
return Arr::get($response, 'data', $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{key:string,label:string,amount:float,currency?:string} $tier
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createPrice(array $tier, string $productId): array
|
||||
{
|
||||
$payload = [
|
||||
'product_id' => $productId,
|
||||
'description' => sprintf('Geschenkgutschein %.2f %s', $tier['amount'], $this->currency($tier)),
|
||||
'unit_price' => [
|
||||
'amount' => (string) $this->toMinorUnits($tier['amount']),
|
||||
'currency_code' => $this->currency($tier),
|
||||
],
|
||||
'custom_data' => [
|
||||
'kind' => 'gift_voucher',
|
||||
'tier_key' => $tier['key'],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->client->post('/prices', $payload);
|
||||
|
||||
return Arr::get($response, 'data', $response);
|
||||
}
|
||||
|
||||
protected function currency(array $tier): string
|
||||
{
|
||||
return Str::upper($tier['currency'] ?? 'EUR');
|
||||
}
|
||||
|
||||
protected function toMinorUnits(float $amount): int
|
||||
{
|
||||
return (int) round($amount * 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Photobooth\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class SparkboothUploadException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $reason,
|
||||
string $message,
|
||||
public readonly ?int $statusCode = null
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class PhotoboothIngestService
|
||||
$this->hasPathColumn = Schema::hasColumn('photos', 'path');
|
||||
}
|
||||
|
||||
public function ingest(Event $event, ?int $maxFiles = null): array
|
||||
public function ingest(Event $event, ?int $maxFiles = null, string $ingestSource = Photo::SOURCE_PHOTOBOOTH): array
|
||||
{
|
||||
$tenant = $event->tenant;
|
||||
|
||||
@@ -90,7 +90,7 @@ class PhotoboothIngestService
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->importFile($event, $eventPackage, $disk, $importDisk, $file);
|
||||
$result = $this->importFile($event, $eventPackage, $disk, $importDisk, $file, $ingestSource);
|
||||
if ($result) {
|
||||
$processed++;
|
||||
Storage::disk($importDisk)->delete($file);
|
||||
@@ -116,6 +116,7 @@ class PhotoboothIngestService
|
||||
string $destinationDisk,
|
||||
string $importDisk,
|
||||
string $file,
|
||||
string $ingestSource,
|
||||
): bool {
|
||||
$stream = Storage::disk($importDisk)->readStream($file);
|
||||
|
||||
@@ -191,8 +192,8 @@ class PhotoboothIngestService
|
||||
'file_path' => $watermarkedPath,
|
||||
'thumbnail_path' => $watermarkedThumb,
|
||||
'status' => 'pending',
|
||||
'guest_name' => Photo::SOURCE_PHOTOBOOTH,
|
||||
'ingest_source' => Photo::SOURCE_PHOTOBOOTH,
|
||||
'guest_name' => $ingestSource,
|
||||
'ingest_source' => $ingestSource,
|
||||
'ip_address' => null,
|
||||
];
|
||||
|
||||
|
||||
@@ -124,6 +124,79 @@ class PhotoboothProvisioner
|
||||
});
|
||||
}
|
||||
|
||||
public function enableSparkbooth(Event $event, ?PhotoboothSetting $settings = null): Event
|
||||
{
|
||||
$settings ??= PhotoboothSetting::current();
|
||||
$event->loadMissing('tenant');
|
||||
|
||||
return DB::transaction(function () use ($event, $settings) {
|
||||
$username = $this->generateUniqueUsername($event, $settings);
|
||||
$password = $this->credentialGenerator->generatePassword();
|
||||
$path = $this->buildPath($event);
|
||||
$expiresAt = $this->resolveExpiry($event, $settings);
|
||||
|
||||
$event->forceFill([
|
||||
'photobooth_enabled' => true,
|
||||
'photobooth_mode' => 'sparkbooth',
|
||||
'sparkbooth_username' => $username,
|
||||
'sparkbooth_password' => $password,
|
||||
'sparkbooth_expires_at' => $expiresAt,
|
||||
'sparkbooth_status' => 'active',
|
||||
'photobooth_path' => $path,
|
||||
'sparkbooth_uploads_last_24h' => 0,
|
||||
])->save();
|
||||
|
||||
return tap($event->refresh(), function (Event $refreshed) use ($password) {
|
||||
$refreshed->setAttribute('plain_sparkbooth_password', $password);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function rotateSparkbooth(Event $event, ?PhotoboothSetting $settings = null): Event
|
||||
{
|
||||
$settings ??= PhotoboothSetting::current();
|
||||
|
||||
if ($event->photobooth_mode !== 'sparkbooth' || ! $event->sparkbooth_username) {
|
||||
return $this->enableSparkbooth($event, $settings);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($event, $settings) {
|
||||
$password = $this->credentialGenerator->generatePassword();
|
||||
$expiresAt = $this->resolveExpiry($event, $settings);
|
||||
|
||||
$event->forceFill([
|
||||
'sparkbooth_password' => $password,
|
||||
'sparkbooth_expires_at' => $expiresAt,
|
||||
'sparkbooth_status' => 'active',
|
||||
'photobooth_enabled' => true,
|
||||
'photobooth_mode' => 'sparkbooth',
|
||||
])->save();
|
||||
|
||||
return tap($event->refresh(), function (Event $refreshed) use ($password) {
|
||||
$refreshed->setAttribute('plain_sparkbooth_password', $password);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function disableSparkbooth(Event $event): Event
|
||||
{
|
||||
return DB::transaction(function () use ($event) {
|
||||
$event->forceFill([
|
||||
'photobooth_enabled' => false,
|
||||
'photobooth_mode' => 'ftp',
|
||||
'sparkbooth_username' => null,
|
||||
'sparkbooth_password' => null,
|
||||
'sparkbooth_expires_at' => null,
|
||||
'sparkbooth_status' => 'inactive',
|
||||
'sparkbooth_last_upload_at' => null,
|
||||
'sparkbooth_uploads_last_24h' => 0,
|
||||
'sparkbooth_uploads_total' => 0,
|
||||
])->save();
|
||||
|
||||
return $event->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
protected function resolveExpiry(Event $event, PhotoboothSetting $settings): CarbonInterface
|
||||
{
|
||||
$eventEnd = $event->date ? Carbon::parse($event->date) : now();
|
||||
@@ -143,6 +216,7 @@ class PhotoboothProvisioner
|
||||
|
||||
$exists = Event::query()
|
||||
->where('photobooth_username', $username)
|
||||
->orWhere('sparkbooth_username', $username)
|
||||
->whereKeyNot($event->getKey())
|
||||
->exists();
|
||||
|
||||
|
||||
138
app/Services/Photobooth/SparkboothUploadService.php
Normal file
138
app/Services/Photobooth/SparkboothUploadService.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Photobooth;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Services\Photobooth\Exceptions\SparkboothUploadException;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SparkboothUploadService
|
||||
{
|
||||
public function __construct(private readonly PhotoboothIngestService $ingestService) {}
|
||||
|
||||
/**
|
||||
* @return array{event: Event, processed: int, skipped: int}
|
||||
*
|
||||
* @throws SparkboothUploadException
|
||||
*/
|
||||
public function handleUpload(UploadedFile $media, ?string $username, ?string $password): array
|
||||
{
|
||||
$event = $this->authenticate($username, $password);
|
||||
|
||||
$this->enforceExpiry($event);
|
||||
$this->enforceRateLimit($event);
|
||||
$this->assertValidFile($media);
|
||||
|
||||
$importDisk = config('photobooth.import.disk', 'photobooth');
|
||||
$basePath = ltrim((string) ($event->photobooth_path ?: $this->buildPath($event)), '/');
|
||||
$extension = strtolower($media->getClientOriginalExtension() ?: $media->extension() ?: 'jpg');
|
||||
$filename = Str::uuid().'.'.$extension;
|
||||
$relativePath = "{$basePath}/{$filename}";
|
||||
|
||||
if (! $event->photobooth_path) {
|
||||
$event->forceFill([
|
||||
'photobooth_path' => $basePath,
|
||||
])->save();
|
||||
}
|
||||
|
||||
Storage::disk($importDisk)->makeDirectory($basePath);
|
||||
Storage::disk($importDisk)->putFileAs($basePath, $media, $filename);
|
||||
|
||||
$summary = $this->ingestService->ingest($event->fresh(), 1, Photo::SOURCE_SPARKBOOTH);
|
||||
|
||||
if (($summary['processed'] ?? 0) < 1) {
|
||||
throw new SparkboothUploadException('ingest_failed', 'Upload failed, please retry.', 500);
|
||||
}
|
||||
|
||||
$event->forceFill([
|
||||
'sparkbooth_last_upload_at' => now(),
|
||||
'sparkbooth_uploads_last_24h' => ($event->sparkbooth_uploads_last_24h ?? 0) + 1,
|
||||
'sparkbooth_uploads_total' => ($event->sparkbooth_uploads_total ?? 0) + 1,
|
||||
])->save();
|
||||
|
||||
return [
|
||||
'event' => $event->fresh(),
|
||||
'processed' => $summary['processed'] ?? 0,
|
||||
'skipped' => $summary['skipped'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function authenticate(?string $username, ?string $password): Event
|
||||
{
|
||||
if (! $username || ! $password) {
|
||||
throw new SparkboothUploadException('missing_credentials', 'Invalid credentials', 401);
|
||||
}
|
||||
|
||||
$normalizedUsername = strtolower(trim($username));
|
||||
|
||||
/** @var Event|null $event */
|
||||
$event = Event::query()
|
||||
->whereRaw('LOWER(sparkbooth_username) = ?', [$normalizedUsername])
|
||||
->first();
|
||||
|
||||
if (! $event) {
|
||||
throw new SparkboothUploadException('invalid_credentials', 'Invalid credentials', 401);
|
||||
}
|
||||
|
||||
if ($event->photobooth_mode !== 'sparkbooth' || ! $event->photobooth_enabled) {
|
||||
throw new SparkboothUploadException('disabled', 'Upload not active for this event', 403);
|
||||
}
|
||||
|
||||
if (! hash_equals($event->sparkbooth_password ?? '', $password ?? '')) {
|
||||
throw new SparkboothUploadException('invalid_credentials', 'Invalid credentials', 401);
|
||||
}
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
protected function enforceExpiry(Event $event): void
|
||||
{
|
||||
if ($event->sparkbooth_expires_at && $event->sparkbooth_expires_at->isPast()) {
|
||||
throw new SparkboothUploadException('expired', 'Upload access has expired', 403);
|
||||
}
|
||||
}
|
||||
|
||||
protected function enforceRateLimit(Event $event): void
|
||||
{
|
||||
$limit = (int) (config('photobooth.sparkbooth.rate_limit_per_minute') ?? 0);
|
||||
if ($limit <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$key = sprintf('sparkbooth:event:%d', $event->id);
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, $limit)) {
|
||||
throw new SparkboothUploadException('rate_limited', 'Upload limit reached; try again in a moment.', 429);
|
||||
}
|
||||
|
||||
RateLimiter::hit($key, 60);
|
||||
}
|
||||
|
||||
protected function assertValidFile(UploadedFile $file): void
|
||||
{
|
||||
$allowed = config('photobooth.sparkbooth.allowed_extensions', ['jpg', 'jpeg', 'png', 'webp']);
|
||||
$extension = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: '');
|
||||
|
||||
if (! $extension || ! in_array($extension, $allowed, true)) {
|
||||
throw new SparkboothUploadException('invalid_file', 'Unsupported file type', 400);
|
||||
}
|
||||
|
||||
$maxSize = (int) config('photobooth.sparkbooth.max_size_kb', 8192) * 1024;
|
||||
$size = $file->getSize() ?? 0;
|
||||
|
||||
if ($maxSize > 0 && $size > $maxSize) {
|
||||
throw new SparkboothUploadException('file_too_large', 'File too large', 400);
|
||||
}
|
||||
}
|
||||
|
||||
protected function buildPath(Event $event): string
|
||||
{
|
||||
$tenantKey = $event->tenant?->slug ?? $event->tenant_id;
|
||||
|
||||
return trim((string) $tenantKey, '/').'/'.$event->getKey();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user