qrWallet()->firstOrCreate( [], ['credit_balance' => 0, 'status' => QrWallet::STATUS_ACTIVE], ); } /** * @param array $data */ public function create(User $user, array $data): QrCode { $wallet = $this->walletFor($user); // Fast path: one cached balance read. Debit is still authoritative (402 if // funds are short). Avoids a second can-afford HTTP round-trip. if (! $wallet->canCreateQr()) { throw new RuntimeException('Add at least GHS ' . number_format(QrWallet::pricePerQr(), 2) . ' to your QR wallet before creating codes.'); } $type = (string) ($data['type'] ?? ''); $validated = $this->payloadValidator->validateForCreate($type, $data); $style = QrStyleDefaults::merge($data['style'] ?? null); // Persist the code first (short DB transaction — no remote billing/image work). $qrCode = DB::transaction(function () use ($user, $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_BOOK) { $bookFile = $data['book_file'] ?? null; if (! $bookFile instanceof UploadedFile) { throw new RuntimeException('Upload the book file (PDF or EPUB).'); } $bookData = $this->storeBookFile($user, $bookFile); $content['file_path'] = $bookData['path']; $content['file_type'] = $bookData['type']; $content['file_size'] = $bookData['size']; if (($data['cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeBookCover($user, $data['cover']); } } 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_PORTFOLIO) { if (($data['portfolio_avatar'] ?? null) instanceof UploadedFile) { $content['avatar_path'] = $this->storeMenuBrandImage($user, $data['portfolio_avatar'], 'portfolio-avatars'); } if (($data['portfolio_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($user, $data['portfolio_cover'], 'portfolio-covers'); } $content['projects'] = $this->injectListImages( $user, $content['projects'] ?? [], $data['project_images'] ?? [], 'portfolio-projects', 'image_path', ); } if ($type === QrCode::TYPE_BOOKSHOP) { if (($data['bookshop_avatar'] ?? null) instanceof UploadedFile) { $content['avatar_path'] = $this->storeMenuBrandImage($user, $data['bookshop_avatar'], 'bookshop-avatars'); } if (($data['bookshop_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($user, $data['bookshop_cover'], 'bookshop-covers'); } $content['books'] = $this->injectListImages( $user, $content['books'] ?? [], $data['book_covers'] ?? [], 'bookshop-books', 'cover_path', ); } if ($type === QrCode::TYPE_CHURCH) { if (($data['church_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($user, $data['church_logo'], 'church-logos'); } if (($data['church_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($user, $data['church_cover'], 'church-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 ($style['logo_path'] === null && ($data['logo'] ?? null) instanceof UploadedFile) { $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); } return 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(), ]); }); try { $this->billing->debitForQrCreation($wallet, $qrCode); } catch (RuntimeException $e) { // Roll back the unpaid code so a failed debit cannot leave a free QR. $qrCode->delete(); throw $e; } // Image render is local CPU work — keep it outside the DB transaction. $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_BOOK) { if (($data['book_file'] ?? null) instanceof UploadedFile) { $bookData = $this->storeBookFile($qrCode->user, $data['book_file']); $content['file_path'] = $bookData['path']; $content['file_type'] = $bookData['type']; $content['file_size'] = $bookData['size']; $qrCode->destination_updated_at = now(); } if (($data['cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeBookCover($qrCode->user, $data['cover']); } } 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_PORTFOLIO) { if (($data['portfolio_avatar'] ?? null) instanceof UploadedFile) { $content['avatar_path'] = $this->storeMenuBrandImage($qrCode->user, $data['portfolio_avatar'], 'portfolio-avatars'); } if (($data['portfolio_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['portfolio_cover'], 'portfolio-covers'); } $content['projects'] = $this->injectListImages( $qrCode->user, $content['projects'] ?? [], $data['project_images'] ?? [], 'portfolio-projects', 'image_path', ); } if ($qrCode->type === QrCode::TYPE_BOOKSHOP) { if (($data['bookshop_avatar'] ?? null) instanceof UploadedFile) { $content['avatar_path'] = $this->storeMenuBrandImage($qrCode->user, $data['bookshop_avatar'], 'bookshop-avatars'); } if (($data['bookshop_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['bookshop_cover'], 'bookshop-covers'); } $content['books'] = $this->injectListImages( $qrCode->user, $content['books'] ?? [], $data['book_covers'] ?? [], 'bookshop-books', 'cover_path', ); } if ($qrCode->type === QrCode::TYPE_CHURCH) { if (($data['church_logo'] ?? null) instanceof UploadedFile) { $content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_logo'], 'church-logos'); } if (($data['church_cover'] ?? null) instanceof UploadedFile) { $content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_cover'], 'church-covers'); } } 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'); } $previousStyle = $style; if (isset($data['style']) && is_array($data['style'])) { $style = QrStyleDefaults::merge(array_merge($style, $data['style'])); } 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'] ?? null)) { $style['logo_path'] = null; $regenerate = true; } // Only re-render the PNG/SVG when style actually changed (forms always post style[]). if (! $regenerate && $this->styleChanged($previousStyle, $style)) { $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']); } /** * @param array $before * @param array $after */ private function styleChanged(array $before, array $after): bool { $before = QrStyleDefaults::merge($before); $after = QrStyleDefaults::merge($after); ksort($before); ksort($after); return $before !== $after; } /** * 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_CHURCH => ['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_BOOK => ['book_title', 'author', 'description', 'price_ghs'], QrCode::TYPE_PORTFOLIO => ['name', 'role', 'bio', 'email', 'phone', 'website', 'brand_color', 'projects', 'social'], QrCode::TYPE_BOOKSHOP => ['author_name', 'tagline', 'bio', 'email', 'website', 'brand_color', 'books', 'social'], QrCode::TYPE_WIFI => ['ssid', 'password', 'encryption', 'hidden'], 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; } /** * Inject uploaded list images (project images / book covers) into a flat list. * * @param array $items * @param array $uploads * @return array */ private function injectListImages(User $user, array $items, array $uploads, string $subdir, string $pathKey): array { if ($uploads === []) { return $items; } foreach ($uploads as $index => $file) { if (! ($file instanceof UploadedFile) || ! isset($items[$index])) { continue; } $mime = $file->getMimeType() ?: ''; if (! str_starts_with($mime, 'image/')) { continue; } $ext = $file->getClientOriginalExtension() ?: 'jpg'; $path = $user->id.'/'.$subdir.'/'.Str::uuid()->toString().'.'.$ext; $file->storeAs('', $path, 'qr'); $items[$index][$pathKey] = $path; } return $items; } 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.'); } }