Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/Hooks/proforma.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

use App\Models\Invoice;
use App\Services\ProformaService;
use Illuminate\Support\Facades\Log;

/**
* A paid proforma is not a tax document: issue the corresponding VAT invoice,
* then re-fire InvoicePaid for it so the KSeF hook (and any other module)
* sees the VAT invoice.
*/
add_hook('InvoicePaid', function (array $params): void {
$invoice = $params['invoice'] ?? null;

if (! $invoice instanceof Invoice) {
return;
}

if (($invoice->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());
}
});
8 changes: 4 additions & 4 deletions app/Http/Controllers/Admin/ClientController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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);
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions app/Http/Controllers/Admin/InvoiceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
23 changes: 21 additions & 2 deletions app/Http/Controllers/Admin/SettingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
}

Expand Down Expand Up @@ -68,6 +80,7 @@ protected function paymentMethods(): array
'FraudLabsApiKey', 'FraudLabsEnabled',
'MaxMindAccountId', 'MaxMindEnabled', 'MaxMindLicenseKey',
'TwilioAccountSid', 'TwilioAuthToken', 'TwilioVerifyEnabled', 'TwilioVerifyServiceSid',
'ProformaEnabled', 'ProformaNumberFormat', 'HidePaidProformas',
'LateFeeAmount', 'LateFeeMinDays', 'LateFeeType',
'MailEnabled', 'MailType', 'MaintenanceMode', 'OrderFormDisplayedOn', 'PhoneNumber',
'SMTPHost', 'SMTPPassword', 'SMTPPort', 'SMTPSecurity', 'SMTPUsername',
Expand Down Expand Up @@ -101,6 +114,12 @@ public function updateGeneral(Request $request)
if (! isset($data['TwilioVerifyEnabled'])) {
$data['TwilioVerifyEnabled'] = '0';
}
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
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/Client/HomeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
];
Expand Down
1 change: 1 addition & 0 deletions app/Http/Controllers/Client/InvoiceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 19 additions & 1 deletion app/Models/Invoice.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -26,6 +26,21 @@ 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. 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);
});
}
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); }
Expand All @@ -45,6 +60,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); }

Expand Down
52 changes: 40 additions & 12 deletions app/Services/InvoiceService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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');
}
Expand Down
91 changes: 91 additions & 0 deletions app/Services/ProformaService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace App\Services;

use App\Enums\InvoiceStatus;
use App\Events\InvoiceCreated;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

/**
* Issues the VAT invoice for a paid proforma.
*
* When the proforma scheme is enabled, new invoices are proformas. Paying a
* proforma replaces it with a VAT invoice: same items and totals, the next
* number in the VAT series, and a link back to the source proforma.
*/
class ProformaService
{
/**
* Issue (once) the VAT invoice for a paid proforma.
*/
public function issueVatInvoice(Invoice $proforma): ?Invoice
{
if (($proforma->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;
}
}
}
Loading
Loading