From df24d4803832185dc5cca40b490bd119afa01eb5 Mon Sep 17 00:00:00 2001 From: Daniel Stock Date: Mon, 25 May 2026 22:45:23 +0200 Subject: [PATCH] Added license management module --- .../Commands/GenerateLicenseKeysCommand.php | 55 +++ app/Console/Kernel.php | 1 + app/Http/Controllers/InvoiceController.php | 2 +- app/Http/Controllers/LicenseController.php | 341 ++++++++++++++++++ app/Models/License.php | 27 ++ database/factories/LicenseFactory.php | 24 ++ ...026_05_25_142250_create_licenses_table.php | 34 ++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/LicenseSeeder.php | 17 + resources/js/components/AppSidebar.vue | 9 +- resources/js/components/ui/crm-badge/index.ts | 2 + resources/js/pages/Licenses.vue | 180 +++++++++ resources/js/services/LicenseService.ts | 126 +++++++ resources/js/types/index.d.ts | 29 +- routes/api.php | 17 +- routes/web.php | 5 + 16 files changed, 861 insertions(+), 9 deletions(-) create mode 100644 app/Console/Commands/GenerateLicenseKeysCommand.php create mode 100644 app/Http/Controllers/LicenseController.php create mode 100644 app/Models/License.php create mode 100644 database/factories/LicenseFactory.php create mode 100644 database/migrations/2026_05_25_142250_create_licenses_table.php create mode 100644 database/seeders/LicenseSeeder.php create mode 100644 resources/js/pages/Licenses.vue create mode 100644 resources/js/services/LicenseService.ts diff --git a/app/Console/Commands/GenerateLicenseKeysCommand.php b/app/Console/Commands/GenerateLicenseKeysCommand.php new file mode 100644 index 0000000..059272b --- /dev/null +++ b/app/Console/Commands/GenerateLicenseKeysCommand.php @@ -0,0 +1,55 @@ +exists($privateKeyPath); + $publicExists = $disk->exists($publicKeyPath); + + if (($privateExists || $publicExists) && !$this->option('force')) { + $this->error('Key pair already exists. Use --force to overwrite.'); + return 1; + } + + $keyPair = $this->generateKeyPair(); + $disk->put($privateKeyPath, $keyPair['privateKey']); + $disk->put($publicKeyPath, $keyPair['publicKey']); + + $this->info('Generated application license key pair.'); + $this->line('Private key: ' . $privateKeyPath); + $this->line('Public key: ' . $publicKeyPath); + + return 0; + } + + protected function generateKeyPair(): array + { + $privateKey = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + openssl_pkey_export($privateKey, $privateKeyPem); + $publicKeyDetails = openssl_pkey_get_details($privateKey); + + return [ + 'privateKey' => $privateKeyPem, + 'publicKey' => $publicKeyDetails['key'], + ]; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 1c0a981..dcc6f1d 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -15,6 +15,7 @@ class Kernel extends ConsoleKernel protected $commands = [ \App\Console\Commands\CaldavSyncCommand::class, \App\Console\Commands\CheckInvoiceDueDatesCommand::class, + \App\Console\Commands\GenerateLicenseKeysCommand::class, ]; /** diff --git a/app/Http/Controllers/InvoiceController.php b/app/Http/Controllers/InvoiceController.php index 6a6d6f9..baddf93 100644 --- a/app/Http/Controllers/InvoiceController.php +++ b/app/Http/Controllers/InvoiceController.php @@ -541,7 +541,7 @@ public function store(Request $request) } } - public function update(Request $request, $id) + public function update(Request $request, int $id) { // Validate input data (expecting camelCase) $validatedData = $request->validate([ diff --git a/app/Http/Controllers/LicenseController.php b/app/Http/Controllers/LicenseController.php new file mode 100644 index 0000000..bc73e9e --- /dev/null +++ b/app/Http/Controllers/LicenseController.php @@ -0,0 +1,341 @@ +get(); + return ApiDataTransformer::snakeToCamel($licenses->toArray()); + } + + /** + * Display the specified resource. + * + * Retrieves a single license by its ID. + * + * @param int $id The ID of the license to retrieve + * @return array + */ + public function single($id) + { + $license = License::findOrFail($id); + return ApiDataTransformer::snakeToCamel($license->toArray()); + } + + /** + * Display the specified resource. + * + * Renders the Inertia view for licenses + * + * @return \Inertia\Response + */ + public function show() + { + return Inertia::render( + 'Licenses', + ['licenseData' => $this->index()] + ); + } + + /** + * Get the directory path for license keys. + * + * @return string + */ + protected function licenseKeyDirectory(): string + { + return 'license-keys'; + } + + /** + * Get the path for the private key file. + * + * @return string + */ + protected function privateKeyPath(): string + { + return $this->licenseKeyDirectory() . '/private.pem'; + } + + /** + * Get the path for the public key file. + * + * @return string + */ + protected function publicKeyPath(): string + { + return $this->licenseKeyDirectory() . '/public.pem'; + } + + /** + * Get the application's key pair. + * + * @return array + */ + protected function getApplicationKeyPair(): array + { + if (!Storage::disk('local')->exists($this->privateKeyPath()) || !Storage::disk('local')->exists($this->publicKeyPath())) { + abort(500, 'Application license key pair is missing. Generate it with `php artisan license:generate-keys`.'); + } + + return [ + 'privateKey' => Storage::disk('local')->get($this->privateKeyPath()), + 'publicKey' => Storage::disk('local')->get($this->publicKeyPath()), + ]; + } + + /** + * Generate a signature for the license data. + * + * Uses the private key to sign the data. + * + * @param array $licenseData The license data to sign + * @param string $privateKey The private key to use for signing + * @return string + */ + protected function generateSignature($licenseData, $privateKey) + { + $licenseJson = json_encode($licenseData); + if ($licenseJson === false) throw new \Exception('Fehler beim JSON-Encode der Lizenzdaten: ' . json_last_error_msg()); + + $success = openssl_sign($licenseJson, $signature, $privateKey, OPENSSL_ALGO_SHA256); + if ($success === false) throw new \Exception('Fehler beim Signieren der Lizenzdaten: ' . openssl_error_string()); + + return base64_encode($signature); + } + + /** + * Store a newly created resource in storage. + * + * Validates the request data, generates a signature, and creates a new license. + * + * @param \Illuminate\Http\Request $request The HTTP request containing license data + * @return array + */ + public function store(Request $request) + { + // Validate the request data + $validatedData = $request->validate([ + 'name' => 'required|string', + 'email' => 'required|email', + 'product' => 'required|string', + 'version' => 'required|string', + 'isPerpetual' => 'required|boolean', + 'expirationDate' => 'nullable|date', + ]); + + // Convert camelCase to snake_case + $snakeCaseData = ApiDataTransformer::camelToSnake($validatedData); + + + $keyPair = $this->getApplicationKeyPair(); + + $licenseData = [ + 'name' => $snakeCaseData['name'], + 'email' => $snakeCaseData['email'], + 'product' => $snakeCaseData['product'], + 'version' => $snakeCaseData['version'], + 'is_perpetual' => $snakeCaseData['is_perpetual'], + 'expiration_date' => $snakeCaseData['expiration_date'], + ]; + + $signature = $this->generateSignature($licenseData, $keyPair['privateKey']); + + $license = License::create([ + 'name' => $snakeCaseData['name'], + 'email' => $snakeCaseData['email'], + 'product' => $snakeCaseData['product'], + 'version' => $snakeCaseData['version'], + 'is_perpetual' => $snakeCaseData['is_perpetual'], + 'expiration_date' => $snakeCaseData['expiration_date'], + 'signature' => $signature, + ]); + + return ApiDataTransformer::snakeToCamel($license->toArray()); + } + + /** + * Validate the license signature and expiration date. + * + * The validation logic should be portable to other languages. + * + * @param \Illuminate\Http\Request $request The HTTP request containing the license ID + * @return array + */ + public function validate(Request $request) + { + // Get the license ID from the request + $licenseId = $request->input('license_id'); + + // Find the license + $license = License::findOrFail($licenseId); + + $publicKey = Storage::disk('local')->get($this->publicKeyPath()); + + $licenseData = [ + 'name' => $license->name, + 'email' => $license->email, + 'product' => $license->product, + 'version' => $license->version, + 'is_perpetual' => $license->is_perpetual, + 'expiration_date' => $license->expiration_date, + ]; + + // Convert the license data to JSON + $licenseJson = json_encode($licenseData); + + // Verify the signature + $signature = base64_decode($license->signature); + $isSignatureValid = openssl_verify($licenseJson, $signature, $publicKey, OPENSSL_ALGO_SHA256); + + // Check the expiration date if the license is not perpetual + $isExpired = false; + if (!$license->is_perpetual && $license->expiration_date) { + $isExpired = now()->gt($license->expiration_date); + } + + // Return the validation result + return [ + 'is_valid' => $isSignatureValid === 1 && !$isExpired, + 'is_signature_valid' => $isSignatureValid === 1, + 'is_expired' => $isExpired, + ]; + } + + /** + * Download the license as a JSON file. + * + * @param int $id The ID of the license to download + * @return \Symfony\Component\HttpFoundation\StreamedResponse + */ + public function download(int $id) + { + // Find the license + $license = License::findOrFail($id); + + // Prepare the license data for download + $licenseData = [ + 'name' => $license->name, + 'email' => $license->email, + 'product' => $license->product, + 'version' => $license->version, + 'is_perpetual' => $license->is_perpetual, + 'expiration_date' => $license->expiration_date, + 'signature' => $license->signature, + ]; + + // Convert the license data to JSON + $licenseJson = json_encode($licenseData, JSON_PRETTY_PRINT); + + // Create a response with the JSON file + $response = response($licenseJson, 200, [ + 'Content-Type' => 'application/json', + 'Content-Disposition' => 'attachment; filename="' . $licenseData['product'] . '.json"', + ]); + + return $response; + } + + /** + * Update the specified resource in storage. + * + * Validates the request data, generates a new signature, and updates the license. + * + * @param \Illuminate\Http\Request $request The HTTP request containing updated license data + * @param int $id The ID of the license to update + * @return array|\Illuminate\Http\JsonResponse + */ + public function update(Request $request, int $id) + { + // Validate the request data + $validatedData = $request->validate([ + 'name' => 'required|string', + 'email' => 'required|email', + 'product' => 'required|string', + 'version' => 'required|string', + 'isPerpetual' => 'required|boolean', + 'expirationDate' => 'nullable|date', + ]); + + // Convert camelCase to snake_case + $snakeCaseData = ApiDataTransformer::camelToSnake($validatedData); + + DB::beginTransaction(); + + try { + // Find license + $license = License::findOrFail($id); + + // Retrieve the private key for the license + $privateKey = $this->getApplicationKeyPair()['privateKey']; + + // Generate the license data + $licenseData = [ + 'name' => $snakeCaseData['name'], + 'email' => $snakeCaseData['email'], + 'product' => $snakeCaseData['product'], + 'version' => $snakeCaseData['version'], + 'is_perpetual' => $snakeCaseData['is_perpetual'], + 'expiration_date' => $snakeCaseData['expiration_date'], + ]; + + // Generate the signature + $signature = $this->generateSignature($licenseData, $privateKey); + + // Update the license + $license->update([ + 'name' => $snakeCaseData['name'], + 'email' => $snakeCaseData['email'], + 'product' => $snakeCaseData['product'], + 'version' => $snakeCaseData['version'], + 'is_perpetual' => $snakeCaseData['is_perpetual'], + 'expiration_date' => $snakeCaseData['expiration_date'], + 'signature' => $signature, + ]); + + DB::commit(); + + // Return the updated license + return ApiDataTransformer::snakeToCamel($license->toArray()); + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'message' => 'Lizenz konnte nicht aktualisiert werden', + 'error' => $e->getMessage() + ], 500); + } + } + + /** + * Remove the specified resource from storage. + * + * Deletes the specified license from the database. + * + * @param int $id The ID of the license to delete + * @return \Illuminate\Http\JsonResponse + */ + public function destroy(int $id) + { + $license = License::findOrFail($id); + $license->delete(); + return response()->json([ + 'message' => 'Lizenz erfolgreich gelöscht' + ]); + } +} diff --git a/app/Models/License.php b/app/Models/License.php new file mode 100644 index 0000000..3e3d8de --- /dev/null +++ b/app/Models/License.php @@ -0,0 +1,27 @@ + */ + use HasFactory; + + protected $fillable = [ + 'name', + 'email', + 'product', + 'version', + 'is_perpetual', + 'expiration_date', + 'signature' + ]; + + protected $casts = [ + 'is_perpetual' => 'boolean', + 'expiration_date' => 'datetime' + ]; +} diff --git a/database/factories/LicenseFactory.php b/database/factories/LicenseFactory.php new file mode 100644 index 0000000..dd6608d --- /dev/null +++ b/database/factories/LicenseFactory.php @@ -0,0 +1,24 @@ + $this->faker->name(), + 'email' => $this->faker->safeEmail(), + 'product' => "Tooloop Guide", + 'version' => $this->faker->semver(), + 'is_perpetual' => rand(0, 10) > 7, + 'expiration_date' => $this->faker->dateTimeThisDecade(), + 'signature' => $this->faker->sha256() + ]; + } +} diff --git a/database/migrations/2026_05_25_142250_create_licenses_table.php b/database/migrations/2026_05_25_142250_create_licenses_table.php new file mode 100644 index 0000000..ed28ffa --- /dev/null +++ b/database/migrations/2026_05_25_142250_create_licenses_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('name'); + $table->string('email'); + $table->string('product'); + $table->string('version'); + $table->boolean('is_perpetual'); + $table->string('expiration_date')->nullable();; + $table->string('signature'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('licenses'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index b3a6fbe..30de5df 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -22,6 +22,7 @@ public function run(): void SettingsTableSeeder::class, TodoTypeSeeder::class, UnitSeeder::class, + LicenseSeeder::class, ]); $user = User::factory()->create([ diff --git a/database/seeders/LicenseSeeder.php b/database/seeders/LicenseSeeder.php new file mode 100644 index 0000000..a7cf076 --- /dev/null +++ b/database/seeders/LicenseSeeder.php @@ -0,0 +1,17 @@ +count(10)->create(); + } +} \ No newline at end of file diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index fd31ac7..b25e594 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -3,12 +3,12 @@ import NavFooter from '@/components/NavFooter.vue'; import NavMain from '@/components/NavMain.vue'; import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarTrigger } from '@/components/ui/crm-sidebar'; import { useSidebar } from '@/components/ui/crm-sidebar/utils' -import { dashboard, pipeline, offers, invoices, newInvoice, products, timesheets, customers, leads, achievements } from '@/routes'; +import { dashboard, pipeline, licenses, offers, invoices, newInvoice, products, timesheets, customers, leads, achievements } from '@/routes'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { Kbd, KbdGroup } from '@/components/ui/kbd' import { type NavGroup } from '@/types'; import { Link, usePage } from '@inertiajs/vue3'; -import { Kanban, Euro, Trophy, Calculator, Timer, ContactRound, Headset, Plus, Package } from 'lucide-vue-next'; +import { Kanban, Euro, Trophy, Calculator, Timer, ContactRound, Headset, Plus, Package, FileBadge } from 'lucide-vue-next'; import AppLogo from './AppLogo.vue'; import { computed } from 'vue'; @@ -66,6 +66,11 @@ const mainNavGroups: NavGroup[] = [ href: products(), icon: Package, }, + { + title: 'Lizenzen', + href: licenses(), + icon: FileBadge, + }, { title: 'Zeiterfassung', href: timesheets(), diff --git a/resources/js/components/ui/crm-badge/index.ts b/resources/js/components/ui/crm-badge/index.ts index fe746af..b1908f2 100644 --- a/resources/js/components/ui/crm-badge/index.ts +++ b/resources/js/components/ui/crm-badge/index.ts @@ -14,6 +14,8 @@ export const badgeVariants = cva( "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", destructive: "border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + success: + "border-transparent bg-success text-success-foreground [a&]:hover:bg-success/90 focus-visible:ring-success/20 dark:focus-visible:ring-success/40 dark:bg-success/60", warning: "border-transparent bg-warning text-warning-foreground [a&]:hover:bg-warning/90 focus-visible:ring-warning/20 dark:focus-visible:ring-warning/40 dark:bg-warning/60", outline: diff --git a/resources/js/pages/Licenses.vue b/resources/js/pages/Licenses.vue new file mode 100644 index 0000000..a4ccbce --- /dev/null +++ b/resources/js/pages/Licenses.vue @@ -0,0 +1,180 @@ + + + diff --git a/resources/js/services/LicenseService.ts b/resources/js/services/LicenseService.ts new file mode 100644 index 0000000..503e7e5 --- /dev/null +++ b/resources/js/services/LicenseService.ts @@ -0,0 +1,126 @@ +import axios, { AxiosError } from 'axios'; +import { License } from '@/types'; +import { toast } from 'vue-sonner'; + +const API_URL = '/api/licenses'; + +export default { + /** + * Fetches all licenses + * @returns Promise + */ + async getLicenses(): Promise { + try { + const response = await axios.get(API_URL); + return response.data; + } catch (error) { + toast.error('Fehler beim Abrufen der Lizenzen', { description: (error as AxiosError).message }); + console.error(error); + return null; + } + }, + + /** + * Fetches a single license by ID + * @param id - The ID of the license to fetch + * @returns Promise + */ + async getLicense(id: number): Promise { + try { + const response = await axios.get(`${API_URL}/${id}`); + return response.data; + } catch (error) { + toast.error('Fehler beim Abrufen der Lizenz', { description: (error as AxiosError).message }); + console.error(error); + return null; + } + }, + + /** + * Creates a new license + * @param license - The license data to create + * @returns Promise + */ + async createLicense(license: License): Promise { + try { + const response = await axios.post(API_URL, license); + return response.data; + } catch (error) { + toast.error('Fehler beim Erstellen der Lizenz', { description: (error as AxiosError).message }); + console.error(error); + return null; + } + }, + + /** + * Updates an existing license + * @param license - The license data to update + * @returns Promise + */ + async updateLicense(license: License): Promise { + try { + const response = await axios.put(`${API_URL}/${license.id}`, license); + return response.data; + } catch (error) { + toast.error('Fehler beim Aktualisieren der Lizenz', { description: (error as AxiosError).message }); + console.error(error); + return null; + } + }, + + /** + * Deletes a license by ID + * @param id - The ID of the license to delete + * @returns Promise + */ + async deleteLicense(id: number): Promise { + try { + await axios.delete(`${API_URL}/${id}`); + return true; + } catch (error) { + toast.error('Fehler beim Löschen der Lizenz', { description: (error as AxiosError).message }); + console.error(error); + return false; + } + }, + + /** + * Downloads a license as a JSON file + * @param id - The ID of the license to download + * @returns Promise + */ + async downloadLicense(id: number): Promise { + try { + const response = await axios.get(`/licenses/download/${id}`, { + responseType: 'blob', + }); + + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `license_${id}.json`); + document.body.appendChild(link); + link.click(); + link.remove(); + } catch (error) { + toast.error('Fehler beim Herunterladen der Lizenz', { description: (error as AxiosError).message }); + console.error(error); + } + }, + + /** + * Validates a license + * @param licenseId - The ID of the license to validate + * @returns Promise<{ is_valid: boolean, is_signature_valid: boolean, is_expired: boolean } | null> + */ + async validateLicense(licenseId: number): Promise<{ is_valid: boolean, is_signature_valid: boolean, is_expired: boolean } | null> { + try { + const response = await axios.post('/api/licenses/validate', { license_id: licenseId }); + return response.data; + } catch (error) { + toast.error('Fehler beim Validieren der Lizenz', { description: (error as AxiosError).message }); + console.error(error); + return null; + } + } +}; \ No newline at end of file diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 72997f9..6037cf2 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -289,6 +289,34 @@ export function newBillingData() { } } +export interface License { + id: number; + name: string; + email: string; + product: string; + version: string; + isPerpetual: boolean; + expirationDate: string | null; + signature: string; + createdAt: string; + updatedAt: string; +} + +export function newLicense(): License { + return { + id: 0, + name: '', + email: '', + product: '', + version: '', + isPerpetual: false, + expirationDate: null, + signature: '', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; +} + export interface LineItem { id: number, invoiceId: number, @@ -411,7 +439,6 @@ export function newProduct(): Product { } } - // Pipeline item used by CRM pipeline export interface PipelineItem { id: number; diff --git a/routes/api.php b/routes/api.php index 1527cf8..0f40343 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,19 +2,20 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\CustomerController; -use App\Http\Controllers\NoteController; use App\Http\Controllers\InvoiceController; +use App\Http\Controllers\LicenseController; use App\Http\Controllers\LineItemController; +use App\Http\Controllers\NoteController; use App\Http\Controllers\PaymentTermsController; +use App\Http\Controllers\PipelineController; +use App\Http\Controllers\PipelineItemController; use App\Http\Controllers\ProductController; use App\Http\Controllers\SettingController; +use App\Http\Controllers\TimesheetController; +use App\Http\Controllers\TimesheetEntryController; use App\Http\Controllers\TodoController; use App\Http\Controllers\UnitController; use App\Mail\OrderConfirmation; -use App\Http\Controllers\TimesheetController; -use App\Http\Controllers\TimesheetEntryController; -use App\Http\Controllers\PipelineController; -use App\Http\Controllers\PipelineItemController; Route::get('/pipeline', [PipelineController::class, 'index']); Route::post('/pipeline/positions', [PipelineController::class, 'updatePositions']); @@ -55,6 +56,12 @@ Route::delete('/invoices/{id}', [InvoiceController::class, 'delete']); Route::get('/invoices/{id}/remind', [InvoiceController::class, 'remind']); +Route::get('/licenses', [\App\Http\Controllers\LicenseController::class, 'index']); +Route::get('/licenses/{id}', [\App\Http\Controllers\LicenseController::class, 'single']); +Route::delete('/licenses/{id}', [\App\Http\Controllers\LicenseController::class, 'destroy']); +Route::post('/licenses', [\App\Http\Controllers\LicenseController::class, 'store']); +Route::put('/licenses/{id}', [\App\Http\Controllers\LicenseController::class, 'update']); + Route::get('/lineitems/{invoiceId}', [LineItemController::class, 'index']); Route::post('/lineitems/import', [LineItemController::class, 'importFromCsv']); diff --git a/routes/web.php b/routes/web.php index 8272cca..6b88aa1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Http\Controllers\ProductController; use App\Http\Controllers\TimesheetController; use App\Http\Controllers\PipelineController; +use App\Http\Controllers\LicenseController; Route::middleware('auth')->group(function () { @@ -49,6 +50,10 @@ Route::get('invoice/{id}/pdf', [InvoiceController::class, 'exportPdf'])->name('invoiceExportPdf'); Route::get('invoice/{id}/xml', [InvoiceController::class, 'exportXml'])->name('invoiceExportXml'); + // Licenses + Route::get('licenses', [LicenseController::class, 'show'])->name('licenses'); + Route::get('licenses/download/{id}', [LicenseController::class, 'download'])->name('downloadLicense'); + // Products Route::get('products', [ProductController::class, 'show'])->name('products');