qrWallet()->firstOrCreate( [], ['credit_balance' => 0, 'status' => QrWallet::STATUS_ACTIVE], ); } /** * @param array $data */ public function create(User $user, array $data): QrCode { $wallet = $this->walletFor($user); $type = (string) ($data['type'] ?? ''); if (! in_array($type, [QrCode::TYPE_PAYMENT, QrCode::TYPE_SHOP], true) && ! $wallet->canCreateQr()) { throw new RuntimeException('Add at least GHS ' . number_format(QrWallet::pricePerQr(), 2) . ' to your QR wallet before creating codes.'); } $validated = $this->payloadValidator->validateForCreate($type, $data); $style = in_array($type, [QrCode::TYPE_PAYMENT, QrCode::TYPE_SHOP], true) ? QrStyleDefaults::defaults() : QrStyleDefaults::merge($data['style'] ?? null); return DB::transaction(function () use ($user, $wallet, $data, $type, $validated, $style) { $documentId = null; $content = $validated['content']; if ($type === QrCode::TYPE_DOCUMENT) { $file = $data['document'] ?? null; if (! $file instanceof UploadedFile) { throw new RuntimeException('A PDF document is required for PDF QR codes.'); } $documentId = $this->storeDocument($user, $file, $data['label'] ?? 'Document')->id; } if ($type === QrCode::TYPE_IMAGE) { $images = $this->storeImages($user, $data); $content['images'] = $images; } if ($type === QrCode::TYPE_VCARD && ($data['avatar'] ?? null) instanceof UploadedFile) { $content['avatar_path'] = $this->storeVcardAvatar($user, $data['avatar']); } if ($type === QrCode::TYPE_BOOKING) { if (($data['menu_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($user, $data['menu_logo'], 'menu-logos'); } if (($data['menu_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($user, $data['menu_cover'], 'menu-covers'); } } if ($type === QrCode::TYPE_APP && ($data['app_icon'] ?? null) instanceof UploadedFile) { $content['icon_path'] = $this->storeMenuBrandImage($user, $data['app_icon'], 'app-icons'); } if (in_array($type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true)) { $content['sections'] = $this->injectItemImages($user, $content['sections'] ?? [], $data, $type); if (($data['menu_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($user, $data['menu_logo'], 'menu-logos'); } if (($data['menu_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($user, $data['menu_cover'], 'menu-covers'); } } if ($type === QrCode::TYPE_BUSINESS) { if (($data['business_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($user, $data['business_logo'], 'business-logos'); } if (($data['business_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($user, $data['business_cover'], 'business-covers'); } } if ($type === QrCode::TYPE_SHOP) { if (($data['church_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($user, $data['church_logo'], 'menu-logos'); } if (($data['church_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($user, $data['church_cover'], 'menu-covers'); } } if ($type === QrCode::TYPE_EVENT) { if (($data['event_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($user, $data['event_logo'], 'event-logos'); } if (($data['event_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($user, $data['event_cover'], 'event-covers'); } } if ($type === QrCode::TYPE_ITINERARY && ($data['itinerary_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($user, $data['itinerary_cover'], 'itinerary-covers'); } if ($type === QrCode::TYPE_PAYMENT && ($data['payment_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($user, $data['payment_logo'], 'payment-logos'); } if ($style['logo_path'] === null && ($data['logo'] ?? null) instanceof UploadedFile && $type !== QrCode::TYPE_PAYMENT) { $style['logo_path'] = $this->storeLogo($user, $data['logo']); } $shortCode = (isset($data['custom_short_code']) && $data['custom_short_code'] !== '') ? (string) $data['custom_short_code'] : $this->generateUniqueShortCode(); $payload = [ 'content' => $content, 'style' => $style, ]; // Freeze the WiFi auto-join payload so the printed code never changes on edit. if ($type === QrCode::TYPE_WIFI) { $payload['wifi_encoded'] = \App\Support\Qr\QrWifiPayload::encode($content); } $qrCode = QrCode::create([ 'user_id' => $user->id, 'short_code' => $shortCode, 'type' => $type, 'label' => trim((string) $data['label']), 'destination_url' => $validated['destination_url'], 'qr_document_id' => $documentId, 'payload' => $payload, 'is_active' => true, 'destination_updated_at' => now(), ]); if (! in_array($type, [QrCode::TYPE_PAYMENT, QrCode::TYPE_SHOP], true)) { $this->billing->debitForQrCreation($wallet, $qrCode); } $this->imageGenerator->generateAndStore($qrCode); return $qrCode->fresh(['document']); }); } /** * @param array $data */ public function update(QrCode $qrCode, array $data): QrCode { $content = $qrCode->content(); $style = $qrCode->style(); $destinationUrl = $qrCode->destination_url; $regenerate = false; if (isset($data['label']) && trim((string) $data['label']) !== '') { $qrCode->label = trim((string) $data['label']); } if (array_key_exists('is_active', $data)) { $qrCode->is_active = (bool) $data['is_active']; } $typeFields = array_merge($content, $data); if ($this->hasContentChanges($qrCode, $data)) { $validated = $this->payloadValidator->validateForUpdate($qrCode, $typeFields); $content = $validated['content']; $destinationUrl = $validated['destination_url']; $qrCode->destination_updated_at = now(); } if ($qrCode->isDocumentType() && isset($data['document']) && $data['document'] instanceof UploadedFile) { $document = $this->storeDocument($qrCode->user, $data['document'], $data['label'] ?? $qrCode->label); $qrCode->qr_document_id = $document->id; $qrCode->destination_updated_at = now(); } if ($qrCode->isImageType()) { $newImages = $this->storeImages($qrCode->user, $data, false); if ($newImages !== []) { $content['images'] = array_merge($content['images'] ?? [], $newImages); $qrCode->destination_updated_at = now(); } } if ($qrCode->type === QrCode::TYPE_VCARD && ($data['avatar'] ?? null) instanceof UploadedFile) { if ($oldPath = $content['avatar_path'] ?? null) { Storage::disk('qr')->delete($oldPath); } $content['avatar_path'] = $this->storeVcardAvatar($qrCode->user, $data['avatar']); } if ($qrCode->type === QrCode::TYPE_BOOKING) { if (($data['menu_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_logo'], 'menu-logos'); } if (($data['menu_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_cover'], 'menu-covers'); } } if ($qrCode->type === QrCode::TYPE_APP && ($data['app_icon'] ?? null) instanceof UploadedFile) { $content['icon_path'] = $this->storeMenuBrandImage($qrCode->user, $data['app_icon'], 'app-icons'); } if (in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true)) { $content['sections'] = $this->injectItemImages($qrCode->user, $content['sections'] ?? [], $data, $qrCode->type); if (($data['menu_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_logo'], 'menu-logos'); } if (($data['menu_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_cover'], 'menu-covers'); } } if ($qrCode->type === QrCode::TYPE_BUSINESS) { if (($data['business_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['business_logo'], 'business-logos'); } if (($data['business_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['business_cover'], 'business-covers'); } } if ($qrCode->type === QrCode::TYPE_SHOP) { if (($data['church_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_logo'], 'menu-logos'); } if (($data['church_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_cover'], 'menu-covers'); } } if ($qrCode->type === QrCode::TYPE_PAYMENT && ($data['payment_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['payment_logo'], 'payment-logos'); } if ($qrCode->type === QrCode::TYPE_EVENT) { if (($data['event_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['event_logo'], 'event-logos'); } if (($data['event_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['event_cover'], 'event-covers'); } } if ($qrCode->type === QrCode::TYPE_ITINERARY && ($data['itinerary_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['itinerary_cover'], 'itinerary-covers'); } if ($qrCode->type === QrCode::TYPE_PAYMENT) { $style = QrStyleDefaults::defaults(); } else { if (isset($data['style']) && is_array($data['style'])) { $style = QrStyleDefaults::merge(array_merge($style, $data['style'])); $regenerate = true; } if (($data['logo'] ?? null) instanceof UploadedFile) { $style['logo_path'] = $this->storeLogo($qrCode->user, $data['logo']); $regenerate = true; } if (($data['remove_logo'] ?? false) && $style['logo_path']) { $style['logo_path'] = null; $regenerate = true; } } $qrCode->destination_url = $destinationUrl; // Preserve frozen keys (e.g. wifi_encoded) so the printed WiFi code stays put. $payload = (array) ($qrCode->payload ?? []); $payload['content'] = $content; $payload['style'] = $style; $qrCode->payload = $payload; $qrCode->save(); if ($regenerate) { $this->imageGenerator->generateAndStore($qrCode); } return $qrCode->fresh(['document']); } /** * Inject uploaded item images into the sections array. * Form field: item_images[sectionIndex][itemIndex] (UploadedFile) * * @param array $sections * @param array $data * @return array */ private function injectItemImages(User $user, array $sections, array $data, string $type): array { $uploads = $data['item_images'] ?? []; if (! is_array($uploads) || $uploads === []) { return $sections; } foreach ($uploads as $sIndex => $itemUploads) { if (! is_array($itemUploads)) { continue; } foreach ($itemUploads as $iIndex => $file) { if (! ($file instanceof UploadedFile)) { continue; } if (! isset($sections[$sIndex]['items'][$iIndex])) { continue; } $mime = $file->getMimeType() ?: ''; if (! str_starts_with($mime, 'image/')) { continue; } $subdir = $type === QrCode::TYPE_SHOP ? 'shop-items' : 'menu-items'; $ext = $file->getClientOriginalExtension() ?: 'jpg'; $path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext; $file->storeAs('', $path, 'qr'); $sections[$sIndex]['items'][$iIndex]['image_path'] = $path; } } return $sections; } /** @param array $data */ private function hasContentChanges(QrCode $qrCode, array $data): bool { $keys = match ($qrCode->type) { QrCode::TYPE_URL => ['destination_url'], QrCode::TYPE_LINK_LIST => ['links'], QrCode::TYPE_VCARD => ['first_name', 'last_name', 'phone', 'email', 'company', 'website', 'address', 'note', 'social'], QrCode::TYPE_BUSINESS => ['name', 'tagline', 'phone', 'email', 'website', 'address', 'hours'], QrCode::TYPE_SHOP => ['name', 'denomination', 'description', 'phone', 'email', 'website', 'address', 'service_times', 'org_type', 'accepts_payment', 'collection_types', 'brand_color'], QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open'], QrCode::TYPE_ITINERARY => ['title', 'subtitle', 'description', 'event_date', 'location', 'brand_color', 'days'], QrCode::TYPE_DOCUMENT => ['allow_download'], QrCode::TYPE_MENU => ['menu_title', 'sections', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'], QrCode::TYPE_SHOP => ['shop_title', 'sections', 'currency', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'], QrCode::TYPE_APP => ['app_name', 'ios_url', 'android_url', 'web_url', 'app_icon'], QrCode::TYPE_BOOKING => ['booking_title', 'description', 'services', 'accepts_payment', 'active_days', 'opens_at', 'closes_at', 'brand_color'], QrCode::TYPE_WIFI => ['ssid', 'password', 'encryption', 'hidden'], QrCode::TYPE_PAYMENT => ['business_name', 'branch_label', 'currency'], default => [], }; foreach ($keys as $key) { if (array_key_exists($key, $data)) { return true; } } return false; } public function storeDocument(User $user, UploadedFile $file, string $title): QrDocument { $maxBytes = (int) config('qr.max_pdf_bytes', 104857600); if ($file->getSize() > $maxBytes) { throw new RuntimeException('PDF must be 100 MB or smaller.'); } $mime = $file->getMimeType() ?: ''; if (! in_array($mime, ['application/pdf', 'application/x-pdf'], true)) { throw new RuntimeException('Only PDF documents are supported.'); } $uuid = Str::uuid()->toString(); $path = $user->id . '/documents/' . $uuid . '.pdf'; $file->storeAs('', $path, 'qr'); return QrDocument::create([ 'user_id' => $user->id, 'title' => $title, 'disk' => 'qr', 'path' => $path, 'mime_type' => 'application/pdf', 'size_bytes' => (int) $file->getSize(), ]); } /** * @param array $data * @return list */ private function storeImages(User $user, array $data, bool $required = true): array { $files = []; if (($data['image'] ?? null) instanceof UploadedFile) { $files[] = $data['image']; } if (is_array($data['images'] ?? null)) { foreach ($data['images'] as $file) { if ($file instanceof UploadedFile) { $files[] = $file; } } } if ($required && $files === []) { $this->payloadValidator->validateImageUpload(null); } $stored = []; foreach ($files as $index => $file) { $this->payloadValidator->validateImageUpload($file); $uuid = Str::uuid()->toString(); $ext = $file->getClientOriginalExtension() ?: 'jpg'; $path = $user->id . '/images/' . $uuid . '.' . $ext; $file->storeAs('', $path, 'qr'); $stored[] = [ 'path' => $path, 'title' => $file->getClientOriginalName() ?: ('Image ' . ($index + 1)), ]; } return $stored; } private function storeVcardAvatar(User $user, UploadedFile $file): string { $mime = $file->getMimeType() ?: ''; if (! str_starts_with($mime, 'image/')) { throw new RuntimeException('Avatar must be an image file.'); } $ext = $file->getClientOriginalExtension() ?: 'jpg'; $path = $user->id . '/vcards/' . Str::uuid()->toString() . '.' . $ext; $file->storeAs('', $path, 'qr'); return $path; } private function storeBookFile(User $user, UploadedFile $file): array { $ext = strtolower($file->getClientOriginalExtension() ?: ''); $mime = $file->getMimeType() ?: ''; if ($ext === 'pdf' || in_array($mime, ['application/pdf', 'application/x-pdf'], true)) { $type = 'pdf'; } elseif ($ext === 'epub' || str_contains($mime, 'epub')) { $type = 'epub'; } else { throw new RuntimeException('Only PDF and EPUB files are supported for books.'); } $path = $user->id . '/books/' . Str::uuid()->toString() . '.' . $type; $file->storeAs('', $path, 'qr'); return ['path' => $path, 'type' => $type, 'size' => (int) $file->getSize()]; } private function storeMenuBrandImage(User $user, UploadedFile $file, string $subdir): string { $mime = $file->getMimeType() ?: ''; if (! str_starts_with($mime, 'image/')) { throw new RuntimeException('Only image files are supported.'); } $ext = $file->getClientOriginalExtension() ?: 'jpg'; $path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext; $file->storeAs('', $path, 'qr'); return $path; } private function storeBookCover(User $user, UploadedFile $file): string { $mime = $file->getMimeType() ?: ''; if (! str_starts_with($mime, 'image/')) { throw new RuntimeException('Book cover must be an image file.'); } $ext = $file->getClientOriginalExtension() ?: 'jpg'; $path = $user->id . '/book-covers/' . Str::uuid()->toString() . '.' . $ext; $file->storeAs('', $path, 'qr'); return $path; } private function storeLogo(User $user, UploadedFile $file): string { $mime = $file->getMimeType() ?: ''; if (! str_starts_with($mime, 'image/')) { throw new RuntimeException('Logo must be an image file.'); } $path = $user->id . '/logos/' . Str::uuid()->toString() . '.' . ($file->getClientOriginalExtension() ?: 'png'); $file->storeAs('', $path, 'qr'); return $path; } private function generateUniqueShortCode(): string { $length = (int) config('qr.short_code_length', 8); for ($attempt = 0; $attempt < 20; $attempt++) { $code = Str::lower(Str::random($length)); if (! QrCode::query()->where('short_code', $code)->exists()) { return $code; } } throw new RuntimeException('Could not generate a unique QR short code.'); } public function delete(QrCode $qrCode): void { $paths = array_filter([$qrCode->png_path, $qrCode->svg_path]); $logoPath = $qrCode->content()['logo_path'] ?? null; if (is_string($logoPath) && $logoPath !== '') { $paths[] = $logoPath; } foreach ($paths as $path) { Storage::disk('qr')->delete($path); } $qrCode->delete(); } }