Add invoice PDF generation with dompdf

Install barryvdh/laravel-dompdf, create professional invoice Blade template
with EZSCALE branding, add download endpoints for customer billing and admin
invoice pages.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 20:17:34 -05:00
parent 0a6780d249
commit 69e0882c81
10 changed files with 881 additions and 11 deletions

View File

@@ -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

View File

@@ -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']);

View File

@@ -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",

514
website/composer.lock generated
View File

@@ -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",

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Invoice;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\InvoiceItem> */
class InvoiceItemFactory extends Factory
{
/** @return array<string, mixed> */
public function definition(): array
{
return [
'invoice_id' => Invoice::factory(),
'description' => fake()->sentence(4),
'amount' => fake()->randomFloat(2, 5, 200),
'quantity' => fake()->numberBetween(1, 3),
];
}
}

View File

@@ -120,11 +120,9 @@ function formatDateTime(dateStr: string | null): string {
<div class="d-flex gap-2">
<VBtn
v-if="invoice.invoice_pdf"
color="info"
variant="tonal"
:href="invoice.invoice_pdf"
target="_blank"
:href="`/invoices/${invoice.id}/download`"
>
<VIcon icon="tabler-download" start />
Download PDF

View File

@@ -54,7 +54,15 @@ defineProps<Props>()
</td>
<td class="text-end">${{ parseFloat(invoice.total).toFixed(2) }}</td>
<td class="text-end">
<a :href="`/billing/invoices/${invoice.id}/download`" class="text-primary text-decoration-none">Download</a>
<VBtn
:href="`/billing/invoices/${invoice.id}/download`"
color="primary"
variant="tonal"
size="small"
>
<VIcon icon="tabler-download" start />
PDF
</VBtn>
</td>
</tr>
</tbody>

View File

@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Invoice {{ $invoice->number }}</title>
<style>
body {
font-family: 'Helvetica', Arial, sans-serif;
font-size: 12px;
color: #333;
margin: 40px;
}
.company-name {
font-size: 24px;
font-weight: bold;
color: #7367F0;
}
.invoice-title {
font-size: 28px;
font-weight: bold;
text-align: right;
color: #7367F0;
}
.invoice-meta {
text-align: right;
margin-top: 8px;
}
.meta-label {
color: #666;
}
.address-label {
font-weight: bold;
font-size: 11px;
text-transform: uppercase;
color: #666;
margin-bottom: 5px;
}
table.items {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
table.items th {
background-color: #7367F0;
color: white;
padding: 8px 12px;
text-align: left;
font-size: 11px;
text-transform: uppercase;
}
table.items th.text-right {
text-align: right;
}
table.items td {
padding: 8px 12px;
border-bottom: 1px solid #eee;
}
table.items tr:nth-child(even) {
background-color: #f9f9f9;
}
.totals {
width: 300px;
margin-left: auto;
}
.totals table {
width: 100%;
}
.totals td {
padding: 4px 8px;
}
.totals .total-row td {
font-size: 16px;
font-weight: bold;
border-top: 2px solid #7367F0;
padding-top: 8px;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
color: white;
}
.status-paid {
background-color: #28c76f;
}
.status-pending, .status-sent {
background-color: #ff9f43;
}
.status-overdue {
background-color: #ea5455;
}
.status-draft {
background-color: #82868b;
}
.status-void {
background-color: #82868b;
}
.footer {
margin-top: 40px;
text-align: center;
font-size: 10px;
color: #999;
border-top: 1px solid #eee;
padding-top: 15px;
}
</style>
</head>
<body>
{{-- Header --}}
<table width="100%" style="margin-bottom: 30px;">
<tr>
<td width="50%" style="vertical-align: top;">
<div class="company-name">EZSCALE</div>
<div style="margin-top: 5px; color: #666;">Cloud Infrastructure Services</div>
</td>
<td width="50%" style="vertical-align: top; text-align: right;">
<div class="invoice-title">INVOICE</div>
<div class="invoice-meta">
<div><span class="meta-label">Invoice #:</span> {{ $invoice->number }}</div>
<div><span class="meta-label">Date:</span> {{ $invoice->created_at->format('M d, Y') }}</div>
<div><span class="meta-label">Due Date:</span> {{ $invoice->due_date ? $invoice->due_date->format('M d, Y') : 'N/A' }}</div>
<div style="margin-top: 5px;">
<span class="status-badge status-{{ strtolower($invoice->status) }}">{{ ucfirst($invoice->status) }}</span>
</div>
</div>
</td>
</tr>
</table>
{{-- Bill To / From --}}
<table width="100%" style="margin-bottom: 30px;">
<tr>
<td width="50%" style="vertical-align: top;">
<div class="address-label">Bill To</div>
<div><strong>{{ $invoice->user->name }}</strong></div>
<div>{{ $invoice->user->email }}</div>
@if($invoice->user->company)<div>{{ $invoice->user->company }}</div>@endif
@if($invoice->user->phone)<div>{{ $invoice->user->phone }}</div>@endif
</td>
<td width="50%" style="vertical-align: top;">
<div class="address-label">From</div>
<div><strong>EZSCALE</strong></div>
<div>support@ezscale.cloud</div>
</td>
</tr>
</table>
{{-- Line Items --}}
<table class="items">
<thead>
<tr>
<th>Description</th>
<th class="text-right" style="text-align: center;">Qty</th>
<th class="text-right" style="text-align: right;">Unit Price</th>
<th class="text-right" style="text-align: right;">Total</th>
</tr>
</thead>
<tbody>
@forelse($invoice->items as $item)
<tr>
<td>{{ $item->description }}</td>
<td style="text-align: center;">{{ $item->quantity }}</td>
<td style="text-align: right;">${{ number_format((float) $item->amount, 2) }}</td>
<td style="text-align: right;">${{ number_format((float) $item->amount * $item->quantity, 2) }}</td>
</tr>
@empty
<tr>
<td>Service charges</td>
<td style="text-align: center;">1</td>
<td style="text-align: right;">${{ number_format((float) $invoice->total - (float) $invoice->tax, 2) }}</td>
<td style="text-align: right;">${{ number_format((float) $invoice->total - (float) $invoice->tax, 2) }}</td>
</tr>
@endforelse
</tbody>
</table>
{{-- Totals --}}
<div class="totals">
<table>
<tr>
<td>Subtotal</td>
<td style="text-align: right;">
@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
</td>
</tr>
@if((float) $invoice->tax > 0)
<tr>
<td>Tax</td>
<td style="text-align: right;">${{ number_format((float) $invoice->tax, 2) }}</td>
</tr>
@endif
<tr class="total-row">
<td>Total</td>
<td style="text-align: right;">${{ number_format((float) $invoice->total, 2) }}</td>
</tr>
@if($invoice->status === 'paid' && $invoice->paid_at)
<tr>
<td colspan="2" style="text-align: right; font-size: 10px; color: #28c76f; padding-top: 4px;">
Paid on {{ $invoice->paid_at->format('M d, Y') }}
</td>
</tr>
@endif
</table>
</div>
{{-- Footer --}}
<div class="footer">
<p>Thank you for your business!</p>
<p>EZSCALE &mdash; Cloud Infrastructure Services &mdash; support@ezscale.cloud</p>
</div>
</body>
</html>

View File

@@ -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([

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->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();
});