diff --git a/website/app/Http/Controllers/Account/BillingController.php b/website/app/Http/Controllers/Account/BillingController.php index fe5c71f..3cc291a 100644 --- a/website/app/Http/Controllers/Account/BillingController.php +++ b/website/app/Http/Controllers/Account/BillingController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Account; use App\Http\Controllers\Controller; use App\Models\Invoice; use App\Services\Billing\BillingServiceFactory; +use Barryvdh\DomPDF\Facade\Pdf; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -93,13 +94,11 @@ class BillingController extends Controller abort(403); } - if ($invoice->invoice_pdf) { - return redirect($invoice->invoice_pdf); - } + $invoice->load(['user', 'items']); - // Generate a basic invoice download as JSON for now - // PDF generation will be added with a dedicated package - return response()->json($invoice->load('items')); + $pdf = Pdf::loadView('pdf.invoice', ['invoice' => $invoice]); + + return $pdf->download("invoice-{$invoice->number}.pdf"); } public function transactions(Request $request): Response diff --git a/website/app/Http/Controllers/Admin/InvoiceController.php b/website/app/Http/Controllers/Admin/InvoiceController.php index a8fb7dd..660c2a0 100644 --- a/website/app/Http/Controllers/Admin/InvoiceController.php +++ b/website/app/Http/Controllers/Admin/InvoiceController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\AuditLog; use App\Models\Invoice; +use Barryvdh\DomPDF\Facade\Pdf; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -59,6 +60,15 @@ class InvoiceController extends Controller ]); } + public function download(Invoice $invoice): \Symfony\Component\HttpFoundation\Response + { + $invoice->load(['user', 'items']); + + $pdf = Pdf::loadView('pdf.invoice', ['invoice' => $invoice]); + + return $pdf->download("invoice-{$invoice->number}.pdf"); + } + public function void(Invoice $invoice): RedirectResponse { $invoice->update(['status' => 'void']); diff --git a/website/composer.json b/website/composer.json index f1d42ec..65746c7 100644 --- a/website/composer.json +++ b/website/composer.json @@ -10,6 +10,7 @@ "license": "MIT", "require": { "php": "^8.2", + "barryvdh/laravel-dompdf": "^3.1", "inertiajs/inertia-laravel": "^2.0", "laravel/cashier": "^16.2", "laravel/fortify": "^1.34", diff --git a/website/composer.lock b/website/composer.lock index b55eea2..fc2b7ca 100644 --- a/website/composer.lock +++ b/website/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7ed919792a4edfaad8af686d9fdf0522", + "content-hash": "5c74851b1987089bba21c4645c42a0f3", "packages": [ { "name": "bacon/bacon-qr-code", @@ -61,6 +61,83 @@ }, "time": "2025-11-19T17:15:36+00:00" }, + { + "name": "barryvdh/laravel-dompdf", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-dompdf.git", + "reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d", + "reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d", + "shasum": "" + }, + "require": { + "dompdf/dompdf": "^3.0", + "illuminate/support": "^9|^10|^11|^12", + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.7|^3.0", + "orchestra/testbench": "^7|^8|^9|^10", + "phpro/grumphp": "^2.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf", + "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf" + }, + "providers": [ + "Barryvdh\\DomPDF\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\DomPDF\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "A DOMPDF Wrapper for Laravel", + "keywords": [ + "dompdf", + "laravel", + "pdf" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-dompdf/issues", + "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-02-13T15:07:54+00:00" + }, { "name": "brick/math", "version": "0.14.7", @@ -549,6 +626,161 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.4", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "db712c90c5b9868df3600e64e68da62e78a34623" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623", + "reference": "db712c90c5b9868df3600e64e68da62e78a34623", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.4" + }, + "time": "2025-10-29T12:43:30+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -2842,6 +3074,73 @@ ], "time": "2026-01-15T06:54:53+00:00" }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, { "name": "moneyphp/money", "version": "v4.8.0", @@ -4676,6 +4975,80 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.1.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb", + "reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.3" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.28 || 2.1.25", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.7", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.6", + "phpunit/phpunit": "8.5.46", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.1.7", + "rector/type-perfect": "1.0.0 || 2.1.0" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.1.0" + }, + "time": "2025-09-14T07:37:21+00:00" + }, { "name": "spatie/laravel-permission", "version": "6.24.0", @@ -7561,6 +7934,145 @@ ], "time": "2026-01-01T22:13:48+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2025-05-14T06:15:44+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.4.0", diff --git a/website/database/factories/InvoiceItemFactory.php b/website/database/factories/InvoiceItemFactory.php new file mode 100644 index 0000000..14aaa5f --- /dev/null +++ b/website/database/factories/InvoiceItemFactory.php @@ -0,0 +1,23 @@ + */ +class InvoiceItemFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'invoice_id' => Invoice::factory(), + 'description' => fake()->sentence(4), + 'amount' => fake()->randomFloat(2, 5, 200), + 'quantity' => fake()->numberBetween(1, 3), + ]; + } +} diff --git a/website/resources/ts/Pages/Admin/Invoices/Show.vue b/website/resources/ts/Pages/Admin/Invoices/Show.vue index 05c404b..a665be6 100644 --- a/website/resources/ts/Pages/Admin/Invoices/Show.vue +++ b/website/resources/ts/Pages/Admin/Invoices/Show.vue @@ -120,11 +120,9 @@ function formatDateTime(dateStr: string | null): string {
Download PDF diff --git a/website/resources/ts/Pages/Billing/Invoices.vue b/website/resources/ts/Pages/Billing/Invoices.vue index 5fd3b60..8df8351 100644 --- a/website/resources/ts/Pages/Billing/Invoices.vue +++ b/website/resources/ts/Pages/Billing/Invoices.vue @@ -54,7 +54,15 @@ defineProps() ${{ parseFloat(invoice.total).toFixed(2) }} - Download + + + PDF + diff --git a/website/resources/views/pdf/invoice.blade.php b/website/resources/views/pdf/invoice.blade.php new file mode 100644 index 0000000..abbdb1c --- /dev/null +++ b/website/resources/views/pdf/invoice.blade.php @@ -0,0 +1,219 @@ + + + + + Invoice {{ $invoice->number }} + + + + {{-- Header --}} + + + + + +
+
EZSCALE
+
Cloud Infrastructure Services
+
+
INVOICE
+
+
Invoice #: {{ $invoice->number }}
+
Date: {{ $invoice->created_at->format('M d, Y') }}
+
Due Date: {{ $invoice->due_date ? $invoice->due_date->format('M d, Y') : 'N/A' }}
+
+ {{ ucfirst($invoice->status) }} +
+
+
+ + {{-- Bill To / From --}} + + + + + +
+
Bill To
+
{{ $invoice->user->name }}
+
{{ $invoice->user->email }}
+ @if($invoice->user->company)
{{ $invoice->user->company }}
@endif + @if($invoice->user->phone)
{{ $invoice->user->phone }}
@endif +
+
From
+
EZSCALE
+
support@ezscale.cloud
+
+ + {{-- Line Items --}} + + + + + + + + + + + @forelse($invoice->items as $item) + + + + + + + @empty + + + + + + + @endforelse + +
DescriptionQtyUnit PriceTotal
{{ $item->description }}{{ $item->quantity }}${{ number_format((float) $item->amount, 2) }}${{ number_format((float) $item->amount * $item->quantity, 2) }}
Service charges1${{ number_format((float) $invoice->total - (float) $invoice->tax, 2) }}${{ number_format((float) $invoice->total - (float) $invoice->tax, 2) }}
+ + {{-- Totals --}} +
+ + + + + + @if((float) $invoice->tax > 0) + + + + + @endif + + + + + @if($invoice->status === 'paid' && $invoice->paid_at) + + + + @endif +
Subtotal + @if($invoice->items->count() > 0) + ${{ number_format($invoice->items->sum(fn ($item) => (float) $item->amount * $item->quantity), 2) }} + @else + ${{ number_format((float) $invoice->total - (float) $invoice->tax, 2) }} + @endif +
Tax${{ number_format((float) $invoice->tax, 2) }}
Total${{ number_format((float) $invoice->total, 2) }}
+ Paid on {{ $invoice->paid_at->format('M d, Y') }} +
+
+ + {{-- Footer --}} + + + diff --git a/website/routes/admin.php b/website/routes/admin.php index f6210d2..5584516 100644 --- a/website/routes/admin.php +++ b/website/routes/admin.php @@ -36,6 +36,7 @@ Route::post('services/{service}/unsuspend', [ServiceController::class, 'unsuspen Route::post('services/{service}/terminate', [ServiceController::class, 'terminate'])->name('services.terminate'); Route::resource('invoices', InvoiceController::class)->only(['index', 'show']); +Route::get('invoices/{invoice}/download', [InvoiceController::class, 'download'])->name('invoices.download'); Route::post('invoices/{invoice}/void', [InvoiceController::class, 'void'])->name('invoices.void'); Route::resource('coupons', CouponController::class)->names([ diff --git a/website/tests/Feature/InvoicePdfTest.php b/website/tests/Feature/InvoicePdfTest.php new file mode 100644 index 0000000..6742b36 --- /dev/null +++ b/website/tests/Feature/InvoicePdfTest.php @@ -0,0 +1,99 @@ +seed(RoleAndPermissionSeeder::class); + $this->accountUrl = 'http://'.config('app.domains.account'); + $this->adminUrl = 'http://'.config('app.domains.admin'); +}); + +it('allows customer to download their own invoice as PDF', function (): void { + $user = User::factory()->customer()->create(); + $invoice = Invoice::factory()->create(['user_id' => $user->id]); + InvoiceItem::factory()->create([ + 'invoice_id' => $invoice->id, + 'description' => 'VPS Hosting - Basic Plan', + 'amount' => '29.99', + 'quantity' => 1, + ]); + + $response = $this->actingAs($user) + ->get($this->accountUrl.'/billing/invoices/'.$invoice->id.'/download'); + + $response->assertOk(); + $response->assertHeader('content-type', 'application/pdf'); + expect($response->headers->get('content-disposition')) + ->toContain("invoice-{$invoice->number}.pdf"); +}); + +it('prevents customer from downloading another users invoice', function (): void { + $user = User::factory()->customer()->create(); + $otherUser = User::factory()->customer()->create(); + $invoice = Invoice::factory()->create(['user_id' => $otherUser->id]); + + $this->actingAs($user) + ->get($this->accountUrl.'/billing/invoices/'.$invoice->id.'/download') + ->assertForbidden(); +}); + +it('allows admin to download any invoice as PDF', function (): void { + $admin = User::factory()->admin()->create(); + $customer = User::factory()->customer()->create(); + $invoice = Invoice::factory()->create(['user_id' => $customer->id]); + + $response = $this->actingAs($admin) + ->get($this->adminUrl.'/invoices/'.$invoice->id.'/download'); + + $response->assertOk(); + $response->assertHeader('content-type', 'application/pdf'); + expect($response->headers->get('content-disposition')) + ->toContain("invoice-{$invoice->number}.pdf"); +}); + +it('returns PDF with correct content-type header', function (): void { + $user = User::factory()->customer()->create(); + $invoice = Invoice::factory()->paid()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user) + ->get($this->accountUrl.'/billing/invoices/'.$invoice->id.'/download'); + + $response->assertOk(); + expect($response->headers->get('content-type'))->toBe('application/pdf'); +}); + +it('includes invoice number in the PDF filename', function (): void { + $user = User::factory()->customer()->create(); + $invoice = Invoice::factory()->create([ + 'user_id' => $user->id, + 'number' => 'INV-TEST-001', + ]); + + $response = $this->actingAs($user) + ->get($this->accountUrl.'/billing/invoices/'.$invoice->id.'/download'); + + $response->assertOk(); + expect($response->headers->get('content-disposition')) + ->toContain('invoice-INV-TEST-001.pdf'); +}); + +it('requires authentication to download invoice', function (): void { + $invoice = Invoice::factory()->create(); + + $this->get($this->accountUrl.'/billing/invoices/'.$invoice->id.'/download') + ->assertRedirect(); +}); + +it('denies customer access to admin invoice download route', function (): void { + $customer = User::factory()->customer()->create(); + $invoice = Invoice::factory()->create(['user_id' => $customer->id]); + + $this->actingAs($customer) + ->get($this->adminUrl.'/invoices/'.$invoice->id.'/download') + ->assertForbidden(); +});