From f72df881ec7672748524904f51b118f52c9dc2e5 Mon Sep 17 00:00:00 2001 From: Grzegorz Date: Mon, 31 Aug 2026 23:49:52 +0200 Subject: [PATCH 1/5] feat(proforma): settings-driven proforma->VAT flow (Enable Proforma scheme) --- app/Hooks/proforma.php | 32 +++++++ .../Controllers/Admin/InvoiceController.php | 4 +- .../Controllers/Admin/SettingController.php | 20 +++- .../Controllers/Client/InvoiceController.php | 1 + app/Models/Invoice.php | 16 +++- app/Services/InvoiceService.php | 52 ++++++++--- app/Services/ProformaService.php | 91 +++++++++++++++++++ ...000006_add_type_and_source_to_invoices.php | 23 +++++ lang/en/admin.php | 2 + lang/pl/admin.php | 2 + .../views/admin/settings/general.blade.php | 16 ++++ 11 files changed, 242 insertions(+), 17 deletions(-) create mode 100644 app/Hooks/proforma.php create mode 100644 app/Services/ProformaService.php create mode 100644 database/migrations/2026_08_31_000006_add_type_and_source_to_invoices.php diff --git a/app/Hooks/proforma.php b/app/Hooks/proforma.php new file mode 100644 index 00000000..cc581155 --- /dev/null +++ b/app/Hooks/proforma.php @@ -0,0 +1,32 @@ +type ?? 'vat') !== 'proforma') { + return; + } + + try { + $vat = app(ProformaService::class)->issueVatInvoice($invoice); + + if ($vat) { + run_hook('InvoicePaid', ['invoice' => $vat, 'transactionId' => $params['transactionId'] ?? null]); + } + } catch (\Throwable $e) { + Log::error('Proforma: could not issue VAT invoice for #'.$invoice->id.': '.$e->getMessage()); + } +}); diff --git a/app/Http/Controllers/Admin/InvoiceController.php b/app/Http/Controllers/Admin/InvoiceController.php index 7eee6b9e..25738d45 100644 --- a/app/Http/Controllers/Admin/InvoiceController.php +++ b/app/Http/Controllers/Admin/InvoiceController.php @@ -37,7 +37,7 @@ public function __construct( public function index(Request $request): View { - $query = Invoice::with('client'); + $query = Invoice::with('client')->excludeSettledProformas(); if ($request->filled('status')) { $query->where('status', $request->status); @@ -411,7 +411,7 @@ public function cancel(Invoice $invoice): RedirectResponse */ public function exportCsv(Request $request): StreamedResponse { - $query = Invoice::with('client'); + $query = Invoice::with('client')->excludeSettledProformas(); if ($request->filled('status')) { $query->where('status', $request->status); diff --git a/app/Http/Controllers/Admin/SettingController.php b/app/Http/Controllers/Admin/SettingController.php index 8c36291b..dae01a40 100644 --- a/app/Http/Controllers/Admin/SettingController.php +++ b/app/Http/Controllers/Admin/SettingController.php @@ -18,15 +18,27 @@ public function general() { $settings = Setting::where('group', 'general')->pluck('value', 'setting'); + $invoiceService = app(\App\Services\InvoiceService::class); + $proformaFormat = trim((string) ($settings['ProformaNumberFormat'] ?? 'PRO-{year}/{month}-{num}')); + $proformaFormat = $proformaFormat !== '' ? $proformaFormat : 'PRO-{year}/{month}-{num}'; + $proformaLast = \App\Models\Invoice::where('invoice_num', 'like', 'PRO-%')->orderBy('id', 'desc')->value('invoice_num'); + $proformaSeq = 1 + (int) \App\Models\Invoice::where('invoice_num', 'like', 'PRO-%') + ->selectRaw('MAX(CAST(REGEXP_REPLACE(invoice_num, "^.*[^0-9]", "") AS UNSIGNED)) as seq') + ->value('seq'); + return view('admin.settings.general', [ 'settings' => $settings, 'mailTransport' => (string) config('mail.default'), 'languages' => Language::active()->orderBy('sort_order')->get(), 'countries' => \App\Support\Countries::all(), 'paymentMethods' => $this->paymentMethods(), - 'invoicePreview' => app(\App\Services\InvoiceService::class)->generateInvoiceNumber(), - 'invoiceNextSeq' => app(\App\Services\InvoiceService::class)->nextInvoiceSequence(), + 'invoicePreview' => $invoiceService->generateInvoiceNumber(), + 'invoiceNextSeq' => $invoiceService->nextInvoiceSequence(), 'invoiceLast' => \App\Models\Invoice::where('invoice_num', '!=', '')->orderBy('id', 'desc')->value('invoice_num'), + 'proformaEnabled' => ($settings['ProformaEnabled'] ?? '0') === '1', + 'proformaFormat' => $proformaFormat, + 'proformaPreview' => $invoiceService->renderInvoiceNumber($proformaFormat, $proformaSeq), + 'proformaLast' => $proformaLast, ]); } @@ -68,6 +80,7 @@ protected function paymentMethods(): array 'FraudLabsApiKey', 'FraudLabsEnabled', 'MaxMindAccountId', 'MaxMindEnabled', 'MaxMindLicenseKey', 'TwilioAccountSid', 'TwilioAuthToken', 'TwilioVerifyEnabled', 'TwilioVerifyServiceSid', + 'ProformaEnabled', 'ProformaNumberFormat', 'LateFeeAmount', 'LateFeeMinDays', 'LateFeeType', 'MailEnabled', 'MailType', 'MaintenanceMode', 'OrderFormDisplayedOn', 'PhoneNumber', 'SMTPHost', 'SMTPPassword', 'SMTPPort', 'SMTPSecurity', 'SMTPUsername', @@ -101,6 +114,9 @@ public function updateGeneral(Request $request) if (! isset($data['TwilioVerifyEnabled'])) { $data['TwilioVerifyEnabled'] = '0'; } + if (! isset($data['ProformaEnabled'])) { + $data['ProformaEnabled'] = '0'; + } // The form never carries the stored mail password back, so an empty // field means the operator did not touch it - not that they want the diff --git a/app/Http/Controllers/Client/InvoiceController.php b/app/Http/Controllers/Client/InvoiceController.php index f7a979b6..2f239fb1 100644 --- a/app/Http/Controllers/Client/InvoiceController.php +++ b/app/Http/Controllers/Client/InvoiceController.php @@ -21,6 +21,7 @@ class InvoiceController extends Controller public function index() { $invoices = Invoice::with('items') + ->excludeSettledProformas() ->where('client_id', $this->getClientId()) ->orderBy('id', 'desc') ->paginate(25); diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index d9c70a15..b252604c 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -8,7 +8,7 @@ class Invoice extends Model { use HasFactory; - protected $fillable = ['client_id', 'invoice_num', 'date', 'due_date', 'date_paid', 'subtotal', 'credit', 'tax', 'tax2', 'total', 'tax_rate', 'tax_rate2', 'status', 'reminder_stage', 'reminder_sent_at', 'payment_method', 'pay_method_id', 'notes', + protected $fillable = ['client_id', 'invoice_num', 'date', 'due_date', 'date_paid', 'subtotal', 'credit', 'tax', 'tax2', 'total', 'tax_rate', 'tax_rate2', 'status', 'type', 'source_invoice_id', 'reminder_stage', 'reminder_sent_at', 'payment_method', 'pay_method_id', 'notes', // Buyer as it stood when the invoice was issued (issue #7). The money // was already frozen; these keep the document itself immutable. 'buyer_first_name', 'buyer_last_name', 'buyer_company_name', 'buyer_email', @@ -26,6 +26,17 @@ public function scopeOutstanding($query) { return $query->whereIn('status', ['unpaid', 'overdue', 'partially_paid']); } + + /** + * Hide proformas that have been settled: once paid, a proforma is replaced + * by its VAT invoice and should no longer show in the invoice lists. + */ + public function scopeExcludeSettledProformas($query) + { + return $query->whereNot(function ($q) { + $q->where('type', 'proforma')->where('status', InvoiceStatus::Paid->value); + }); + } protected function casts(): array { return ['date' => 'date', 'due_date' => 'date', 'date_paid' => 'datetime', 'subtotal' => 'decimal:2', 'credit' => 'decimal:2', 'tax' => 'decimal:2', 'total' => 'decimal:2', 'buyer_custom_fields' => 'array']; } public function client() { return $this->belongsTo(Client::class); } @@ -45,6 +56,9 @@ public function amountDue(): float public function items() { return $this->hasMany(InvoiceItem::class); } public function transactions() { return $this->hasMany(Transaction::class); } + /** The proforma this VAT invoice was issued from. */ + public function sourceInvoice() { return $this->belongsTo(self::class, 'source_invoice_id'); } + public function scopeUnpaid($q) { return $q->where('status', InvoiceStatus::Unpaid->value); } public function scopeOverdue($q) { return $q->where('status', InvoiceStatus::Overdue->value); } diff --git a/app/Services/InvoiceService.php b/app/Services/InvoiceService.php index 6e6776db..1b922175 100644 --- a/app/Services/InvoiceService.php +++ b/app/Services/InvoiceService.php @@ -27,10 +27,12 @@ public function createInvoice(Client $client, array $items, array $options = []) 'client_id' => $client->id, // Freeze the buyer alongside the money (issue #7) ...Invoice::buyerSnapshotFrom($client), - 'invoice_num' => $options['invoice_num'] ?? $this->generateInvoiceNumber(), + 'invoice_num' => $options['invoice_num'] ?? $this->generateInvoiceNumber($options['type'] ?? $this->defaultType()), 'date' => $options['date'] ?? now()->toDateString(), 'due_date' => $options['due_date'] ?? now()->addDays((int) Setting::get('InvoiceDueDays', 14))->toDateString(), 'status' => $options['status'] ?? InvoiceStatus::Unpaid->value, + 'type' => $options['type'] ?? $this->defaultType(), + 'source_invoice_id' => $options['source_invoice_id'] ?? null, 'payment_method' => $options['payment_method'] ?? null, 'notes' => $options['notes'] ?? null, 'subtotal' => 0, @@ -281,29 +283,53 @@ public function applyCredit(Invoice $invoice, float $amount): Invoice * {num} placeholders; {num} is the next number in the series derived * from the last invoice stored in the database. */ - public function generateInvoiceNumber(): string + public function generateInvoiceNumber(?string $type = null): string { - $format = (string) Setting::get('InvoiceNumberFormat', 'INV-{num}'); - if (trim($format) === '') { - $format = 'INV-{num}'; + $type = $type ?: $this->defaultType(); + $format = $this->numberFormatFor($type); + + return $this->renderInvoiceNumber($format, $this->nextInvoiceSequence($format, $type)); + } + + /** + * The default invoice type for new invoices: proforma when the proforma + * scheme is enabled, otherwise VAT. + */ + public function defaultType(): string + { + return Setting::get('ProformaEnabled', '0') === '1' ? 'proforma' : 'vat'; + } + + /** + * The numbering format for a given invoice type. + */ + public function numberFormatFor(?string $type = null): string + { + if ($type === 'proforma') { + $format = (string) Setting::get('ProformaNumberFormat', 'PRO-{year}/{month}-{num}'); + + return trim($format) !== '' ? $format : 'PRO-{year}/{month}-{num}'; } - return $this->renderInvoiceNumber($format, $this->nextInvoiceSequence($format)); + $format = (string) Setting::get('InvoiceNumberFormat', 'INV-{num}'); + + return trim($format) !== '' ? $format : 'INV-{num}'; } /** * The next sequence number for previews (the highest number already * issued plus one). */ - public function nextInvoiceSequence(?string $format = null): int + public function nextInvoiceSequence(?string $format = null, ?string $type = null): int { - $format ??= (string) Setting::get('InvoiceNumberFormat', 'INV-{num}'); + $type = $type ?: $this->defaultType(); + $format ??= $this->numberFormatFor($type); // Without {num} the number has nowhere to grow: fall back to the // row id, which still keeps them unique. $pos = strpos((string) $format, '{num}'); if ($pos === false) { - return 1 + (int) Invoice::max('id'); + return 1 + (int) Invoice::where('type', $type)->max('id'); } // {num} last: the series continues across format changes, reading @@ -313,7 +339,8 @@ public function nextInvoiceSequence(?string $format = null): int // run of digits. The series only grows, so nothing is ever // issued twice. if (substr((string) $format, -5) === '{num}') { - $query = Invoice::where('invoice_num', 'regexp', '[0-9]$') + $query = Invoice::where('type', $type) + ->where('invoice_num', 'regexp', '[0-9]$') ->selectRaw('MAX(CAST(REGEXP_REPLACE(invoice_num, "^.*[^0-9]", "") AS UNSIGNED)) as seq'); // Reset each year: only the numbers issued this year count, @@ -329,12 +356,13 @@ public function nextInvoiceSequence(?string $format = null): int // which is where the digits actually sit on issued numbers. $prefix = substr((string) $format, 0, $pos); if ($prefix === '') { - return 1 + (int) Invoice::max('id'); + return 1 + (int) Invoice::where('type', $type)->max('id'); } $like = addcslashes($prefix, '%_').'%'; - return 1 + (int) Invoice::where('invoice_num', 'like', $like) + return 1 + (int) Invoice::where('type', $type) + ->where('invoice_num', 'like', $like) ->selectRaw('MAX(CAST(SUBSTRING(invoice_num, ?) AS UNSIGNED)) as seq', [strlen($prefix) + 1]) ->value('seq'); } diff --git a/app/Services/ProformaService.php b/app/Services/ProformaService.php new file mode 100644 index 00000000..cbc8c0d7 --- /dev/null +++ b/app/Services/ProformaService.php @@ -0,0 +1,91 @@ +type ?? 'vat') !== 'proforma') { + return null; + } + + $existing = Invoice::where('source_invoice_id', $proforma->id)->first(); + if ($existing) { + return $existing; + } + + try { + $vat = DB::transaction(function () use ($proforma) { + $vat = Invoice::create([ + 'client_id' => $proforma->client_id, + ...Invoice::buyerSnapshotFrom($proforma->client), + 'invoice_num' => app(InvoiceService::class)->generateInvoiceNumber('vat'), + 'date' => now()->toDateString(), + 'due_date' => now()->toDateString(), + 'date_paid' => now(), + 'status' => InvoiceStatus::Paid->value, + 'type' => 'vat', + 'source_invoice_id' => $proforma->id, + 'subtotal' => $proforma->subtotal, + 'credit' => $proforma->credit, + 'tax' => $proforma->tax, + 'tax2' => $proforma->tax2, + 'total' => $proforma->total, + 'tax_rate' => $proforma->tax_rate, + 'tax_rate2' => $proforma->tax_rate2, + 'payment_method' => $proforma->payment_method, + 'notes' => $proforma->notes, + ]); + + foreach ($proforma->items as $item) { + InvoiceItem::create([ + 'invoice_id' => $vat->id, + 'client_id' => $proforma->client_id, + 'type' => $item->type, + 'rel_id' => $item->rel_id, + 'description' => $item->description, + 'qty' => $item->qty, + 'amount' => $item->amount, + 'taxed' => $item->taxed, + 'tax_rate' => $item->tax_rate, + 'tax_label' => $item->tax_label, + 'unit' => $item->unit, + 'due_date' => $item->due_date, + ]); + } + + Log::info('Proforma #'.$proforma->id.' issued VAT invoice #'.$vat->id.' ('.$vat->invoice_num.')'); + + return $vat; + }); + + // Notify the client (with the VAT invoice PDF) — outside the + // transaction so listeners see committed state. + event(new InvoiceCreated($vat)); + + return $vat; + } catch (\Throwable $e) { + Log::error('Could not issue VAT invoice for proforma #'.$proforma->id.': '.$e->getMessage()); + + return null; + } + } +} diff --git a/database/migrations/2026_08_31_000006_add_type_and_source_to_invoices.php b/database/migrations/2026_08_31_000006_add_type_and_source_to_invoices.php new file mode 100644 index 00000000..8e2dbbe6 --- /dev/null +++ b/database/migrations/2026_08_31_000006_add_type_and_source_to_invoices.php @@ -0,0 +1,23 @@ +string('type', 20)->default('vat')->after('status'); + $table->unsignedBigInteger('source_invoice_id')->nullable()->after('type'); + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + $table->dropColumn(['type', 'source_invoice_id']); + }); + } +}; diff --git a/lang/en/admin.php b/lang/en/admin.php index 08429c18..382eb9fd 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -2250,6 +2250,8 @@ 'general_settings' => 'General Settings', 'homepage_builder' => 'Homepage Builder', 'invoice_number_format' => 'Invoice numbering scheme', + 'proforma_enabled' => 'Enable Proforma scheme', + 'proforma_number_format' => 'Proforma numbering scheme', 'invoice_number_last' => 'Last invoice', 'invoice_number_preview' => 'Preview', 'invoice_number_reset_year' => 'Reset numbering each year', diff --git a/lang/pl/admin.php b/lang/pl/admin.php index a6ab7586..7b64104c 100644 --- a/lang/pl/admin.php +++ b/lang/pl/admin.php @@ -2254,6 +2254,8 @@ 'general_settings' => 'Ustawienia ogólne', 'homepage_builder' => 'Kreator strony głównej', 'invoice_number_format' => 'Schemat numeracji faktur', + 'proforma_enabled' => 'Włącz schemat proform', + 'proforma_number_format' => 'Schemat numeracji proform', 'invoice_number_last' => 'Ostatnia faktura', 'invoice_number_preview' => 'Podgląd', 'invoice_number_reset_year' => 'Resetuj numerację co roku', diff --git a/resources/views/admin/settings/general.blade.php b/resources/views/admin/settings/general.blade.php index 5f82db17..8344711f 100644 --- a/resources/views/admin/settings/general.blade.php +++ b/resources/views/admin/settings/general.blade.php @@ -225,6 +225,22 @@ {{ __('admin.settings.invoice_number_preview') }}: {{ $invoicePreview }} + +
+
+ + +
+ {{ __('admin.settings.invoice_number_last') }}: + {{ $proformaLast ?? '—' }} + {{ __('admin.settings.invoice_number_preview') }}: + {{ $proformaPreview }} +
+
+
From b183f86e228a46f7a96ab4ffc63e7af79aebc70f Mon Sep 17 00:00:00 2001 From: Grzegorz Date: Mon, 31 Aug 2026 23:53:13 +0200 Subject: [PATCH 2/5] feat(proforma): show source proforma reference on VAT invoice (admin/client/PDF) --- lang/en/admin.php | 1 + lang/pl/admin.php | 1 + resources/views/admin/invoices/show.blade.php | 3 +++ resources/views/client/invoices/show.blade.php | 3 +++ resources/views/pdf/invoice.blade.php | 3 +++ 5 files changed, 11 insertions(+) diff --git a/lang/en/admin.php b/lang/en/admin.php index 382eb9fd..fbdcc5f3 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -1223,6 +1223,7 @@ 'invoices.gateway' => 'Gateway', 'invoices.invoice_cancelled' => 'Invoice Cancelled', 'invoices.invoice_date' => 'Invoice Date', + 'invoices.source_proforma' => 'Refers to proforma', 'invoices.invoice_details' => 'Invoice Details', 'invoices.invoice_hash' => 'Invoice #', 'invoices.line_items' => 'Line Items', diff --git a/lang/pl/admin.php b/lang/pl/admin.php index 7b64104c..a0b74783 100644 --- a/lang/pl/admin.php +++ b/lang/pl/admin.php @@ -1228,6 +1228,7 @@ 'invoices.gateway' => 'Bramka', 'invoices.invoice_cancelled' => 'Faktura anulowana', 'invoices.invoice_date' => 'Data faktury', + 'invoices.source_proforma' => 'Dotyczy proformy', 'invoices.invoice_details' => 'Szczegóły faktury', 'invoices.invoice_hash' => 'Faktura #', 'invoices.line_items' => 'Pozycje', diff --git a/resources/views/admin/invoices/show.blade.php b/resources/views/admin/invoices/show.blade.php index 3b713d76..d3ef84ba 100644 --- a/resources/views/admin/invoices/show.blade.php +++ b/resources/views/admin/invoices/show.blade.php @@ -228,6 +228,9 @@
+ @if($invoice->sourceInvoice) + + @endif @if($invoice->payment_method) diff --git a/resources/views/client/invoices/show.blade.php b/resources/views/client/invoices/show.blade.php index 1a887cbd..f47ca5b3 100644 --- a/resources/views/client/invoices/show.blade.php +++ b/resources/views/client/invoices/show.blade.php @@ -11,6 +11,9 @@

{{ __('client.invoices.invoice_prefix', ['id' => $invoice->invoice_num ?? $invoice->id]) }}

{{ __('client.invoices.issued') }} {{ $invoice->date?->format(date_fmt()) ?? "N/A" }}

+ @if($invoice->sourceInvoice) +

{{ __('admin.invoices.source_proforma') }}: {{ $invoice->sourceInvoice->invoice_num }}

+ @endif
{{ __('client.invoices.download_pdf') }}{{ invoice_status_label($invoice->status) }} diff --git a/resources/views/pdf/invoice.blade.php b/resources/views/pdf/invoice.blade.php index 741ad65c..84eff694 100644 --- a/resources/views/pdf/invoice.blade.php +++ b/resources/views/pdf/invoice.blade.php @@ -55,6 +55,9 @@
{{ __('pdf.invoice') }}
#{{ $invoice->invoice_num ?? $invoice->id }}
+ @if($invoice->sourceInvoice) +
{{ __('admin.invoices.source_proforma') }}: {{ $invoice->sourceInvoice->invoice_num }}
+ @endif
@php $statusClass = match(strtolower($invoice->status)) { From ff4a825cfdbe04c96852f8fd737addef6da16c53 Mon Sep 17 00:00:00 2001 From: Grzegorz Date: Tue, 1 Sep 2026 00:14:21 +0200 Subject: [PATCH 3/5] feat(proforma): add Hide paid proformas setting; exclude proformas from income reports --- app/Http/Controllers/Admin/SettingController.php | 5 ++++- app/Http/Controllers/Client/HomeController.php | 2 +- app/Models/Invoice.php | 6 +++++- lang/en/admin.php | 1 + lang/pl/admin.php | 1 + modules/Reports/IncomeByProductReport.php | 1 + modules/Reports/SalesTaxLiabilityReport.php | 2 +- resources/views/admin/settings/general.blade.php | 4 ++++ 8 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Admin/SettingController.php b/app/Http/Controllers/Admin/SettingController.php index dae01a40..712a63ba 100644 --- a/app/Http/Controllers/Admin/SettingController.php +++ b/app/Http/Controllers/Admin/SettingController.php @@ -80,7 +80,7 @@ protected function paymentMethods(): array 'FraudLabsApiKey', 'FraudLabsEnabled', 'MaxMindAccountId', 'MaxMindEnabled', 'MaxMindLicenseKey', 'TwilioAccountSid', 'TwilioAuthToken', 'TwilioVerifyEnabled', 'TwilioVerifyServiceSid', - 'ProformaEnabled', 'ProformaNumberFormat', + 'ProformaEnabled', 'ProformaNumberFormat', 'HidePaidProformas', 'LateFeeAmount', 'LateFeeMinDays', 'LateFeeType', 'MailEnabled', 'MailType', 'MaintenanceMode', 'OrderFormDisplayedOn', 'PhoneNumber', 'SMTPHost', 'SMTPPassword', 'SMTPPort', 'SMTPSecurity', 'SMTPUsername', @@ -117,6 +117,9 @@ public function updateGeneral(Request $request) if (! isset($data['ProformaEnabled'])) { $data['ProformaEnabled'] = '0'; } + if (! isset($data['HidePaidProformas'])) { + $data['HidePaidProformas'] = '0'; + } // The form never carries the stored mail password back, so an empty // field means the operator did not touch it - not that they want the diff --git a/app/Http/Controllers/Client/HomeController.php b/app/Http/Controllers/Client/HomeController.php index 1495cb2f..ac406c7c 100644 --- a/app/Http/Controllers/Client/HomeController.php +++ b/app/Http/Controllers/Client/HomeController.php @@ -22,7 +22,7 @@ public function index() 'domainCount' => Domain::whereIn('client_id', $clientIds)->where('status', DomainStatus::Active->value)->count(), 'unpaidInvoices' => Invoice::whereIn('client_id', $clientIds)->outstanding()->count(), 'openTickets' => Ticket::whereIn('client_id', $clientIds)->stillOpen()->count(), - 'recentInvoices' => Invoice::whereIn('client_id', $clientIds)->orderBy('id', 'desc')->limit(5)->get(), + 'recentInvoices' => Invoice::whereIn('client_id', $clientIds)->excludeSettledProformas()->orderBy('id', 'desc')->limit(5)->get(), 'recentTickets' => Ticket::whereIn('client_id', $clientIds)->orderBy('id', 'desc')->limit(5)->get(), 'activeServices' => Service::whereIn('client_id', $clientIds)->where('status', ServiceStatus::Active->value)->with('product')->limit(5)->get(), ]; diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index b252604c..00155f41 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -29,10 +29,14 @@ public function scopeOutstanding($query) /** * Hide proformas that have been settled: once paid, a proforma is replaced - * by its VAT invoice and should no longer show in the invoice lists. + * by its VAT invoice. Gated on the "hide paid proformas" setting. */ public function scopeExcludeSettledProformas($query) { + if (Setting::get('HidePaidProformas', '1') !== '1') { + return $query; + } + return $query->whereNot(function ($q) { $q->where('type', 'proforma')->where('status', InvoiceStatus::Paid->value); }); diff --git a/lang/en/admin.php b/lang/en/admin.php index fbdcc5f3..957747f4 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -2253,6 +2253,7 @@ 'invoice_number_format' => 'Invoice numbering scheme', 'proforma_enabled' => 'Enable Proforma scheme', 'proforma_number_format' => 'Proforma numbering scheme', + 'hide_paid_proformas' => 'Hide paid proformas', 'invoice_number_last' => 'Last invoice', 'invoice_number_preview' => 'Preview', 'invoice_number_reset_year' => 'Reset numbering each year', diff --git a/lang/pl/admin.php b/lang/pl/admin.php index a0b74783..9564ee73 100644 --- a/lang/pl/admin.php +++ b/lang/pl/admin.php @@ -2257,6 +2257,7 @@ 'invoice_number_format' => 'Schemat numeracji faktur', 'proforma_enabled' => 'Włącz schemat proform', 'proforma_number_format' => 'Schemat numeracji proform', + 'hide_paid_proformas' => 'Nie pokazuj opłaconych proform', 'invoice_number_last' => 'Ostatnia faktura', 'invoice_number_preview' => 'Podgląd', 'invoice_number_reset_year' => 'Resetuj numerację co roku', diff --git a/modules/Reports/IncomeByProductReport.php b/modules/Reports/IncomeByProductReport.php index 3a0c1ed6..29e3e526 100644 --- a/modules/Reports/IncomeByProductReport.php +++ b/modules/Reports/IncomeByProductReport.php @@ -21,6 +21,7 @@ public function generate(Request $request): array ->leftJoin("products", "products.id", "=", "services.product_id") ->selectRaw("COALESCE(products.name, invoice_items.description) as product, COUNT(DISTINCT invoices.id) as invoices, SUM(invoice_items.amount) as revenue") ->where("invoices.status", "paid") + ->where("invoices.type", "!=", "proforma") ->whereBetween("invoices.date_paid", [$from, $to.' 23:59:59']) ->groupBy("product")->orderBy("revenue", "desc")->get(); return ["columns" => ["Product", "Invoices", "Revenue"], "rows" => $rows->toArray(), "totals" => ["Total", $rows->sum("invoices"), $rows->sum("revenue")]]; diff --git a/modules/Reports/SalesTaxLiabilityReport.php b/modules/Reports/SalesTaxLiabilityReport.php index 860ab6bc..f4916c3e 100644 --- a/modules/Reports/SalesTaxLiabilityReport.php +++ b/modules/Reports/SalesTaxLiabilityReport.php @@ -17,7 +17,7 @@ public function generate(Request $request): array [$from, $to] = $this->getDateRange($request); $rows = DB::table("invoices") ->selectRaw("DATE_FORMAT(date_paid, '%Y-%m') as month, SUM(subtotal) as subtotal, SUM(tax) as tax, SUM(tax2) as tax2, SUM(total) as total") - ->where("status", "paid")->whereBetween("date_paid", [$from, $to.' 23:59:59']) + ->where("status", "paid")->where("type", "!=", "proforma")->whereBetween("date_paid", [$from, $to.' 23:59:59']) ->groupBy("month")->orderBy("month", "desc")->get(); return ["columns" => ["Month", "Subtotal", "Tax", "Tax 2", "Total"], "rows" => $rows->toArray(), "totals" => ["Total", $rows->sum("subtotal"), $rows->sum("tax"), $rows->sum("tax2"), $rows->sum("total")]]; } diff --git a/resources/views/admin/settings/general.blade.php b/resources/views/admin/settings/general.blade.php index 8344711f..a5245599 100644 --- a/resources/views/admin/settings/general.blade.php +++ b/resources/views/admin/settings/general.blade.php @@ -240,6 +240,10 @@ {{ $proformaPreview }}
+ From af751c422927facd542cb097f823ce3796bd2559 Mon Sep 17 00:00:00 2001 From: Grzegorz Date: Tue, 1 Sep 2026 00:26:08 +0200 Subject: [PATCH 4/5] fix(proforma): hide settled proformas in admin client invoices tab --- app/Http/Controllers/Admin/ClientController.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Admin/ClientController.php b/app/Http/Controllers/Admin/ClientController.php index becd53f0..68be4e11 100644 --- a/app/Http/Controllers/Admin/ClientController.php +++ b/app/Http/Controllers/Admin/ClientController.php @@ -110,7 +110,7 @@ public function show(Request $request, Client $client) // Stats always needed (shown in all tabs) $data['serviceCount'] = $client->services()->count(); $data['domainCount'] = $client->domains()->count(); - $data['invoiceCount'] = $client->invoices()->count(); + $data['invoiceCount'] = $client->invoices()->excludeSettledProformas()->count(); $data['ticketCount'] = $client->tickets()->count(); $data['unpaidInvoices'] = $client->invoices()->where('status', 'unpaid')->sum('total'); @@ -122,7 +122,7 @@ public function show(Request $request, Client $client) $data['domains'] = $client->domains()->orderBy('id', 'desc')->paginate(15); break; case 'invoices': - $data['invoices'] = $client->invoices()->orderBy('id', 'desc')->paginate(15); + $data['invoices'] = $client->invoices()->excludeSettledProformas()->orderBy('id', 'desc')->paginate(15); break; case 'tickets': $data['tickets'] = $client->tickets()->with('department')->orderBy('id', 'desc')->paginate(15); @@ -137,10 +137,10 @@ public function show(Request $request, Client $client) default: // summary $data['serviceCount'] = $client->services()->count(); $data['domainCount'] = $client->domains()->count(); - $data['invoiceCount'] = $client->invoices()->count(); + $data['invoiceCount'] = $client->invoices()->excludeSettledProformas()->count(); $data['ticketCount'] = $client->tickets()->count(); $data['unpaidInvoices'] = $client->invoices()->where('status', 'unpaid')->sum('total'); - $data['recentInvoices'] = $client->invoices()->orderBy('id', 'desc')->limit(5)->get(); + $data['recentInvoices'] = $client->invoices()->excludeSettledProformas()->orderBy('id', 'desc')->limit(5)->get(); $data['recentTickets'] = $client->tickets()->with('department')->orderBy('id', 'desc')->limit(5)->get(); $data['recentServices'] = $client->services()->with('product')->orderBy('id', 'desc')->limit(5)->get(); break; From 291968715b7d91a326466008b6db680eb2ee1100 Mon Sep 17 00:00:00 2001 From: Grzegorz Date: Tue, 1 Sep 2026 10:07:36 +0200 Subject: [PATCH 5/5] feat(proforma): add zh/tr translations for proforma settings --- lang/tr/admin.php | 4 +++- lang/zh/admin.php | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lang/tr/admin.php b/lang/tr/admin.php index 97179ad2..52a960fd 100644 --- a/lang/tr/admin.php +++ b/lang/tr/admin.php @@ -2207,7 +2207,9 @@ 'services.status_hint_cancelled' => 'Hizmet iptal edildi', 'services.delete_hint' => 'Hizmeti müşteriden ayır ve kalıcı olarak sil', 'settings' => [ - 'admin_login_prefix' => 'Yönetici Günlükin URL Prefix', + 'proforma_number_format' => 'Proforma numaralandırma düzeni', + 'proforma_enabled' => 'Proforma düzeni etkinleştir', + 'admin_login_prefix' => 'Yonetici Gunlukin URL Prefix', 'appearance' => 'Appearance', 'appearance_settings' => 'Appearance Ayarlatings', 'change_password' => 'Şifreyi değiştir', diff --git a/lang/zh/admin.php b/lang/zh/admin.php index 4ac98b8d..e7fdfc47 100644 --- a/lang/zh/admin.php +++ b/lang/zh/admin.php @@ -2207,6 +2207,8 @@ 'services.status_hint_cancelled' => '服务已取消', 'services.delete_hint' => '将服务与客户解除关联并永久删除', 'settings' => [ + 'proforma_number_format' => '形式发票编号方案', + 'proforma_enabled' => '启用形式发票方案', 'admin_login_prefix' => '管理员登录 URL 前缀', 'appearance' => '外观', 'appearance_settings' => '外观设置',
{{ __('admin.invoices.invoice_hash') }}{{ $invoice->invoice_num }}
{{ __('admin.invoices.source_proforma') }}{{ $invoice->sourceInvoice->invoice_num }}
{{ __('admin.invoices.date') }}{{ $invoice->date?->format(date_fmt()) }}
{{ __('admin.invoices.due_date') }}{{ $invoice->due_date?->format(date_fmt()) }}