From 0e6726b0a6ed0a6e142ef58f5393d165d3afc842 Mon Sep 17 00:00:00 2001 From: I-Fun Date: Tue, 7 Apr 2026 07:49:35 +0700 Subject: [PATCH] feat: add legacy PDF generation support and invoice discount fields --- app/Http/Controllers/InvoiceController.php | 9 + app/Models/Invoice.php | 36 +- app/Models/InvoiceItem.php | 9 +- app/Services/InvoiceService.php | 11 + .../2026_04_06_152600_add_discount_fields.php | 38 ++ resources/views/invoices/create.blade.php | 105 ++++- resources/views/invoices/edit.blade.php | 107 ++++- resources/views/invoices/index.blade.php | 2 + resources/views/invoices/pdf-legacy.blade.php | 373 ++++++++++++++++++ resources/views/invoices/pdf.blade.php | 12 +- resources/views/invoices/show.blade.php | 15 +- resources/views/rate-cards/public.blade.php | 37 +- routes/web.php | 1 + 13 files changed, 710 insertions(+), 45 deletions(-) create mode 100644 database/migrations/2026_04_06_152600_add_discount_fields.php create mode 100644 resources/views/invoices/pdf-legacy.blade.php diff --git a/app/Http/Controllers/InvoiceController.php b/app/Http/Controllers/InvoiceController.php index bf90314..8e02735 100644 --- a/app/Http/Controllers/InvoiceController.php +++ b/app/Http/Controllers/InvoiceController.php @@ -56,6 +56,8 @@ class InvoiceController extends Controller 'items.*.quantity' => 'required|integer|min:1', 'items.*.unit_price' => 'required|numeric|min:0', 'items.*.is_taxable' => 'nullable|boolean', + 'items.*.is_discountable' => 'nullable|boolean', + 'discount_rate' => 'nullable|integer|min:0|max:100', ]); $items = $validated['items']; @@ -118,6 +120,8 @@ class InvoiceController extends Controller 'items.*.quantity' => 'required|integer|min:1', 'items.*.unit_price' => 'required|numeric|min:0', 'items.*.is_taxable' => 'nullable|boolean', + 'items.*.is_discountable' => 'nullable|boolean', + 'discount_rate' => 'nullable|integer|min:0|max:100', ]); $items = $validated['items']; @@ -151,4 +155,9 @@ class InvoiceController extends Controller { return $this->invoiceService->generatePdf($invoice); } + + public function pdfLegacy(Invoice $invoice): Response + { + return $this->invoiceService->generateLegacyPdf($invoice); + } } diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index 0551896..088ac33 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -21,7 +21,7 @@ class Invoice extends Model 'subtotal', 'tax_rate', 'tax_amount', 'total', 'currency', 'status', 'issue_date', 'due_date', 'paid_date', 'notes', 'bank_name', 'bank_account', 'bank_holder', 'branch_name', - 'signature_image', 'signer_name', 'signer_title', + 'signature_image', 'signer_name', 'signer_title', 'discount_rate', ]; protected $casts = [ @@ -47,14 +47,32 @@ class Invoice extends Model public function recalculate(): void { - // Total of all items (what the creator receives) + // Total of all items $this->subtotal = $this->items->sum('total'); - // Tax is calculated only on taxable items, but paid by the client on top - $taxableSubtotal = $this->items->where('is_taxable', true)->sum('total'); + $discountAmount = 0; + if ($this->discount_rate > 0) { + $discountableSubtotal = $this->items->where('is_discountable', true)->sum('total'); + $discountAmount = round((float) $discountableSubtotal * ($this->discount_rate / 100), 2); + } + + $this->subtotal = $this->subtotal - $discountAmount; + + // Tax is calculated only on taxable items + $taxableSubtotal = 0; + foreach ($this->items as $item) { + if ($item->is_taxable) { + if ($this->discount_rate > 0 && $item->is_discountable) { + $taxableSubtotal += $item->total * (1 - $this->discount_rate / 100); + } else { + $taxableSubtotal += $item->total; + } + } + } + $this->tax_amount = round((float) $taxableSubtotal * ($this->tax_rate / 100), 2); - // Grand total = subtotal (creator's fee, pre-tax β€” tax is borne by client separately) + // Grand total = subtotal (tax is borne by client separately) $this->total = $this->subtotal; $this->save(); } @@ -64,6 +82,14 @@ class Invoice extends Model return 'Rp ' . number_format((float) $this->total, 0, ',', '.'); } + public function getDiscountAmountAttribute(): float + { + if ($this->discount_rate <= 0) return 0.0; + + $discountableSubtotal = $this->items->where('is_discountable', true)->sum('total'); + return round((float) $discountableSubtotal * ($this->discount_rate / 100), 2); + } + public static function generateNumber(): string { return InvoiceSettings::generateNumber(); diff --git a/app/Models/InvoiceItem.php b/app/Models/InvoiceItem.php index 7affe3c..b0157f0 100644 --- a/app/Models/InvoiceItem.php +++ b/app/Models/InvoiceItem.php @@ -9,12 +9,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class InvoiceItem extends Model { - protected $fillable = ['invoice_id', 'description', 'quantity', 'unit_price', 'total', 'is_taxable']; + protected $fillable = ['invoice_id', 'description', 'quantity', 'unit_price', 'total', 'is_taxable', 'is_discountable']; protected $casts = [ - 'unit_price' => 'decimal:2', - 'total' => 'decimal:2', - 'is_taxable' => 'boolean', + 'unit_price' => 'decimal:2', + 'total' => 'decimal:2', + 'is_taxable' => 'boolean', + 'is_discountable' => 'boolean', ]; public function invoice(): BelongsTo diff --git a/app/Services/InvoiceService.php b/app/Services/InvoiceService.php index bcb1ccd..a9e8815 100644 --- a/app/Services/InvoiceService.php +++ b/app/Services/InvoiceService.php @@ -59,4 +59,15 @@ final class InvoiceService return $pdf->download("invoice-{$invoice->invoice_number}.pdf"); } + + public function generateLegacyPdf(Invoice $invoice): Response + { + $invoice->load('client', 'items'); + $invoiceSettings = InvoiceSettings::instance(); + + $pdf = Pdf::loadView('invoices.pdf-legacy', compact('invoice', 'invoiceSettings')); + $pdf->setPaper('A4', 'portrait'); + + return $pdf->download("invoice-{$invoice->invoice_number}-legacy.pdf"); + } } diff --git a/database/migrations/2026_04_06_152600_add_discount_fields.php b/database/migrations/2026_04_06_152600_add_discount_fields.php new file mode 100644 index 0000000..e7fd0ee --- /dev/null +++ b/database/migrations/2026_04_06_152600_add_discount_fields.php @@ -0,0 +1,38 @@ +integer('discount_rate')->default(0); + } + }); + + Schema::table('invoice_items', function (Blueprint $table) { + if (!Schema::hasColumn('invoice_items', 'is_discountable')) { + $table->boolean('is_discountable')->default(true); + } + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + if (Schema::hasColumn('invoices', 'discount_rate')) { + $table->dropColumn('discount_rate'); + } + }); + + Schema::table('invoice_items', function (Blueprint $table) { + if (Schema::hasColumn('invoice_items', 'is_discountable')) { + $table->dropColumn('is_discountable'); + } + }); + } +}; diff --git a/resources/views/invoices/create.blade.php b/resources/views/invoices/create.blade.php index 463a250..4774a0b 100644 --- a/resources/views/invoices/create.blade.php +++ b/resources/views/invoices/create.blade.php @@ -98,16 +98,28 @@ - {{-- Taxable checkbox --}} -
- - - + {{-- Taxable and Discount checkboxes --}} +
+
+ + + +
+
+ + + +
@@ -116,10 +128,26 @@ {{-- Totals --}}
+
+ + +
+
Subtotal (all items) Rp
+
+ Discount (%) + - Rp +
Tax (% β€” paid by client) Rp @@ -211,19 +239,68 @@ function invoiceForm() { return { showCustomFields: {{ old('use_custom_fields', false) ? 'true' : 'false' }}, - items: [{ description: @json($invoiceSettings->default_service_description ?? ''), quantity: 1, unit_price: 0, is_taxable: true }], + addDiscount: false, + discountRate: 5, + items: [{ description: @json($invoiceSettings->default_service_description ?? ''), quantity: 1, unit_price: 0, is_taxable: true, is_discountable: false }], taxRate: {{ $invoiceSettings->default_tax_rate ?? 11 }}, get subtotal() { return this.items.reduce((s, i) => s + (i.quantity * i.unit_price), 0); }, + get discountAmount() { + if (!this.addDiscount) return 0; + const discountable = this.items + .filter(i => i.is_discountable) + .reduce((s, i) => s + (i.quantity * i.unit_price), 0); + return Math.round(discountable * (this.discountRate / 100)); + }, + get totalSubtotal() { + return this.subtotal - this.discountAmount; + }, get taxAmount() { const taxable = this.items .filter(i => i.is_taxable) - .reduce((s, i) => s + (i.quantity * i.unit_price), 0); + .reduce((s, i) => { + let itemTotal = i.quantity * i.unit_price; + if (this.addDiscount && i.is_discountable) { + itemTotal -= itemTotal * (this.discountRate / 100); + } + return s + itemTotal; + }, 0); return Math.round(taxable * this.taxRate / 100); }, - get total() { return this.subtotal; }, // grand total = pre-tax (tax borne by client) - addItem() { this.items.push({ description: '', quantity: 1, unit_price: 0, is_taxable: true }); }, + get total() { return this.totalSubtotal; }, // grand total = pre-tax + getDiscountLabel() { + const labels = { + 5: 'Small thank-you discount πŸ™', + 10: 'Appreciation bonus ✨', + 15: 'Loyalty perk unlocked 🀝', + 20: 'Client appreciation deal πŸ’Ό', + 25: 'Special partner discount ⭐', + 30: 'Valued client offer 🎯', + 35: 'Priority client savings πŸ“Œ', + 40: 'Strong appreciation rate πŸ’Ž', + 45: 'Preferred client pricing πŸ…', + 50: 'Half-off partner special πŸŽ‰', + 55: 'Premium loyalty reward 🧩', + 60: 'Major appreciation offer πŸš€', + 65: 'VIP-level savings πŸ†', + 70: 'Elite client advantage πŸ‘‘', + 75: 'Strategic partner bonus 🀝✨', + 80: 'Executive-tier discount πŸ’ΌπŸ”₯', + 85: 'Signature client reward πŸ–‹οΈ', + 90: 'Ultimate partnership rate 🌟' + }; + return labels[this.discountRate] || ''; + }, + toggleDiscount() { + if (this.addDiscount) { + this.items.forEach(i => i.is_discountable = true); + } else { + // We can leave them checked or uncheck them, usually uncheck + this.items.forEach(i => i.is_discountable = false); + } + }, + addItem() { this.items.push({ description: '', quantity: 1, unit_price: 0, is_taxable: true, is_discountable: this.addDiscount }); }, removeItem(i) { if (this.items.length > 1) this.items.splice(i, 1); }, recalc() { }, formatNum(n) { return Number(n).toLocaleString('id-ID'); }, diff --git a/resources/views/invoices/edit.blade.php b/resources/views/invoices/edit.blade.php index 9b44213..3a30f41 100644 --- a/resources/views/invoices/edit.blade.php +++ b/resources/views/invoices/edit.blade.php @@ -95,17 +95,30 @@
- {{-- Taxable checkbox --}} -
- - - + {{-- Taxable and Discount checkboxes --}} +
+
+ + + +
+
+ + + +
@@ -114,10 +127,26 @@ {{-- Totals --}}
+
+ + +
+
Subtotal (all items) Rp
+
+ Discount (%) + - Rp +
Tax (% β€” paid by client) Rp @@ -223,24 +252,72 @@ function invoiceEditForm() { @php $itemsJson = $invoice->items->map(function($i) { - return ['description' => $i->description, 'quantity' => $i->quantity, 'unit_price' => (float)$i->unit_price, 'is_taxable' => (bool)$i->is_taxable]; + return ['description' => $i->description, 'quantity' => $i->quantity, 'unit_price' => (float)$i->unit_price, 'is_taxable' => (bool)$i->is_taxable, 'is_discountable' => (bool)$i->is_discountable]; }); @endphp return { showCustomFields: {{ old('use_custom_fields', ($invoice->bank_name || $invoice->signature_image || $invoice->signer_name || $invoice->signer_title) ? true : false) ? 'true' : 'false' }}, + addDiscount: {{ $invoice->discount_rate > 0 ? 'true' : 'false' }}, + discountRate: {{ $invoice->discount_rate ?: 5 }}, items: @json($itemsJson), taxRate: {{ $invoice->tax_rate }}, get subtotal() { return this.items.reduce((s, i) => s + (i.quantity * i.unit_price), 0); }, + get discountAmount() { + if (!this.addDiscount) return 0; + const discountable = this.items + .filter(i => i.is_discountable) + .reduce((s, i) => s + (i.quantity * i.unit_price), 0); + return Math.round(discountable * (this.discountRate / 100)); + }, + get totalSubtotal() { + return this.subtotal - this.discountAmount; + }, get taxAmount() { const taxable = this.items .filter(i => i.is_taxable) - .reduce((s, i) => s + (i.quantity * i.unit_price), 0); + .reduce((s, i) => { + let itemTotal = i.quantity * i.unit_price; + if (this.addDiscount && i.is_discountable) { + itemTotal -= itemTotal * (this.discountRate / 100); + } + return s + itemTotal; + }, 0); return Math.round(taxable * this.taxRate / 100); }, - get total() { return this.subtotal; }, - addItem() { this.items.push({ description: '', quantity: 1, unit_price: 0, is_taxable: true }); }, + get total() { return this.totalSubtotal; }, // grand total = pre-tax + getDiscountLabel() { + const labels = { + 5: 'Small thank-you discount πŸ™', + 10: 'Appreciation bonus ✨', + 15: 'Loyalty perk unlocked 🀝', + 20: 'Client appreciation deal πŸ’Ό', + 25: 'Special partner discount ⭐', + 30: 'Valued client offer 🎯', + 35: 'Priority client savings πŸ“Œ', + 40: 'Strong appreciation rate πŸ’Ž', + 45: 'Preferred client pricing πŸ…', + 50: 'Half-off partner special πŸŽ‰', + 55: 'Premium loyalty reward 🧩', + 60: 'Major appreciation offer πŸš€', + 65: 'VIP-level savings πŸ†', + 70: 'Elite client advantage πŸ‘‘', + 75: 'Strategic partner bonus 🀝✨', + 80: 'Executive-tier discount πŸ’ΌπŸ”₯', + 85: 'Signature client reward πŸ–‹οΈ', + 90: 'Ultimate partnership rate 🌟' + }; + return labels[this.discountRate] || ''; + }, + toggleDiscount() { + if (this.addDiscount) { + this.items.forEach(i => i.is_discountable = true); + } else { + this.items.forEach(i => i.is_discountable = false); + } + }, + addItem() { this.items.push({ description: '', quantity: 1, unit_price: 0, is_taxable: true, is_discountable: this.addDiscount }); }, removeItem(i) { if (this.items.length > 1) this.items.splice(i, 1); }, recalc() {}, formatNum(n) { return Number(n).toLocaleString('id-ID'); }, diff --git a/resources/views/invoices/index.blade.php b/resources/views/invoices/index.blade.php index 97cbd5a..e7ff5b2 100644 --- a/resources/views/invoices/index.blade.php +++ b/resources/views/invoices/index.blade.php @@ -89,6 +89,8 @@ class="text-xs text-pink-500 hover:underline">View PDF + PDF (Legacy) Edit {{-- Share button disabled --}} diff --git a/resources/views/invoices/pdf-legacy.blade.php b/resources/views/invoices/pdf-legacy.blade.php new file mode 100644 index 0000000..0d8dcba --- /dev/null +++ b/resources/views/invoices/pdf-legacy.blade.php @@ -0,0 +1,373 @@ + + + + + + + + + + + + + + + +
+
+
+
I N V O I C E
+
+ + + + + + +
+
BILLING TO:
+ @if($invoice->client) +
{{ $invoice->client->company }}
+ + @if($invoice->client->full_address) +
{!! nl2br(e($invoice->client->full_address)) !!}
@endif + @else +
β€”
+ @endif +
+ + + + + + + + + + + + + +
Invoice No:{{ $invoice->invoice_number }}
Date:{{ $invoice->issue_date->format('d.m.Y') }}
Due Date:{{ $invoice->due_date->format('d.m.Y') }}
+
+ +
+
FROM:
+
+ {{ $invoiceSettings->header_name ?: 'Cathy' }} + @if($invoiceSettings->header_title) + ( {{ $invoiceSettings->header_title }} ) + @endif +
+
+ + + + + + + + + + + + @foreach($invoice->items as $item) + + + + + + + @endforeach + +
DESCRIPTIONPRICEQTYTOTAL
+ {{ $item->description }} + @if(!$item->is_taxable) + Non-Taxable + @endif + @if($invoice->discount_rate > 0 && !$item->is_discountable) + No + Discount + @endif + Rp {{ number_format($item->unit_price, 0, ',', '.') }}{{ $item->quantity }}Rp {{ number_format($item->total, 0, ',', '.') }}
+ +
+ + + + + + @if($invoice->discount_amount > 0) + + + + + @endif + @if($invoice->tax_rate > 0) + + + + + @endif + + + + +
SUBTOTALRp + {{ number_format($invoice->subtotal + $invoice->discount_amount, 0, ',', '.') }} +
DISCOUNT ({{ $invoice->discount_rate }}%)- Rp + {{ number_format($invoice->discount_amount, 0, ',', '.') }} +
TAX ({{ $invoice->tax_rate }}%)Rp + {{ number_format($invoice->tax_amount, 0, ',', '.') }} +
GRAND TOTAL{{ $invoice->formatted_total }}
+
+ + @if($invoice->bank_name) +
+
PAYMENT ABOVE SHOULD BE MADE TO:
+

+ {{ $invoice->bank_name }}
+ A/N {{ $invoice->bank_holder }}
+ {{ $invoice->bank_account }} + @if($invoice->branch_name)
KCP: {{ $invoice->branch_name }}@endif +

+
+ @endif + + + + + + \ No newline at end of file diff --git a/resources/views/invoices/pdf.blade.php b/resources/views/invoices/pdf.blade.php index 961c658..ddcbfc1 100644 --- a/resources/views/invoices/pdf.blade.php +++ b/resources/views/invoices/pdf.blade.php @@ -317,6 +317,10 @@ Non-Taxable @endif + @if($invoice->discount_rate > 0 && !$item->is_discountable) + No Discount + @endif Rp {{ number_format($item->unit_price, 0, ',', '.') }} {{ $item->quantity }} @@ -332,8 +336,14 @@ - + + @if($invoice->discount_amount > 0) + + + + + @endif @if($invoice->tax_rate > 0) diff --git a/resources/views/invoices/show.blade.php b/resources/views/invoices/show.blade.php index 6263ba4..5f7ba34 100644 --- a/resources/views/invoices/show.blade.php +++ b/resources/views/invoices/show.blade.php @@ -11,6 +11,9 @@ πŸ“₯ Download PDF + πŸ“₯ + PDF (Legacy) @csrf @method('DELETE') @@ -104,6 +107,10 @@ Non-Taxable @endif + @if($invoice->discount_rate > 0 && !$item->is_discountable) + No Discount + @endif @@ -120,8 +127,14 @@
SubtotalRp - {{ number_format($invoice->subtotal, 0, ',', '.') }} + {{ number_format($invoice->subtotal + $invoice->discount_amount, 0, ',', '.') }}
+ @if($invoice->discount_amount > 0) +
+ Discount ({{ $invoice->discount_rate }}%) + - Rp {{ number_format($invoice->discount_amount, 0, ',', '.') }} +
+ @endif @if($invoice->tax_rate > 0)
Tax ({{ $invoice->tax_rate }}% β€” paid by client) diff --git a/resources/views/rate-cards/public.blade.php b/resources/views/rate-cards/public.blade.php index 79972ab..fd1bb06 100644 --- a/resources/views/rate-cards/public.blade.php +++ b/resources/views/rate-cards/public.blade.php @@ -405,18 +405,45 @@ @if(!empty($publicShowcase))
Content Showcase
- @php $isCarousel = count($publicShowcase) >= 3; @endphp + @php + $isCarousel = count($publicShowcase) >= 3; + $renderShowcase = $isCarousel ? array_merge($publicShowcase, $publicShowcase) : $publicShowcase; + @endphp @if($isCarousel)
SubtotalRp {{ number_format($invoice->subtotal, 0, ',', '.') }}Rp {{ number_format($invoice->subtotal + $invoice->discount_amount, 0, ',', '.') }}
Discount ({{ $invoice->discount_rate }}%)- Rp {{ number_format($invoice->discount_amount, 0, ',', '.') }}
Tax ({{ $invoice->tax_rate }}% β€” paid by client)
Rp {{ number_format($item->unit_price, 0, ',', '.') }}