refactor(sales): rewrite legacy-era regions of the document services, controllers, credit-note request and routes in place

This commit is contained in:
Darko Gjorgjijoski
2026-08-21 01:26:47 +02:00
parent 28ce4ed264
commit 7170ba8241
7 changed files with 257 additions and 223 deletions
@@ -20,6 +20,7 @@ use App\Platform\Pdf\Rendering\PdfTemplateUtils;
use App\Support\Hashids\HashidConnection;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\View;
class EstimateService implements EstimatePdfDataProvider
{
@@ -45,14 +46,17 @@ class EstimateService implements EstimatePdfDataProvider
$estimate = Estimate::create($attributes);
$estimate->unique_hash = Hashids::connection(HashidConnection::Estimate->value)->encode($estimate->id);
$serial = (new SerialNumberService)
->setModel($estimate)
->setCompany($estimate->company_id)
->setCustomer($estimate->customer_id)
->setModel($estimate)
->setNextNumbers();
$estimate->sequence_number = $serial->nextSequenceNumber;
$estimate->customer_sequence_number = $serial->nextCustomerSequenceNumber;
$estimate->save();
// Both sequences fall out of the same resolution pass. The visible
// number itself is rendered client-side and arrived with the payload.
$estimate->fill([
'sequence_number' => $serial->nextSequenceNumber,
'customer_sequence_number' => $serial->nextCustomerSequenceNumber,
])->save();
$companyCurrency = CompanySetting::getSetting('currency', $estimate->company_id);
@@ -81,8 +85,8 @@ class EstimateService implements EstimatePdfDataProvider
?iterable $customFields = null,
): Estimate {
$serial = (new SerialNumberService)
->setModel($estimate)
->setCompany($estimate->company_id)
->setModel($estimate)
->setCustomer($attributes['customer_id'])
->setModelObject($estimate->id)
->setNextNumbers();
@@ -97,13 +101,13 @@ class EstimateService implements EstimatePdfDataProvider
$this->exchangeRateRecorder->record($estimate);
}
$estimate->items->map(function ($item) {
$fields = $item->fields()->get();
$fields->map(function ($field) {
$field->delete();
});
});
// Answers to item-level custom fields have no cascade of their own,
// so they are cleared row by row before the items are replaced.
foreach ($estimate->items as $lineItem) {
foreach ($lineItem->fields()->get() as $answer) {
$answer->delete();
}
}
$estimate->items()->delete();
$estimate->taxes()->delete();
@@ -118,13 +122,8 @@ class EstimateService implements EstimatePdfDataProvider
$this->customFieldValueWriter->update($estimate, $customFields);
}
return Estimate::with([
'items.taxes',
'items.fields',
'items.fields.customField',
'customer',
'taxes',
])->findOrFail($estimate->id);
return Estimate::with(['items.taxes', 'items.fields', 'items.fields.customField', 'customer', 'taxes'])
->findOrFail($estimate->id);
}
public function sendEstimateData(Estimate $estimate, array $data): array
@@ -163,15 +162,15 @@ class EstimateService implements EstimatePdfDataProvider
if ($estimate->tax_per_item === 'YES') {
foreach ($estimate->items as $item) {
foreach ($item->taxes as $tax) {
$found = $taxes->filter(function ($item) use ($tax) {
return $item->tax_type_id == $tax->tax_type_id;
})->first();
foreach ($item->taxes as $appliedTax) {
// Rows of one tax type collapse onto the first row seen for
// it, which then carries the running total for the document.
$running = $taxes->first(fn ($seen) => $seen->tax_type_id == $appliedTax->tax_type_id);
if ($found) {
$found->amount += $tax->amount;
if ($running) {
$running->amount += $appliedTax->amount;
} else {
$taxes->push($tax);
$taxes->push($appliedTax);
}
}
}
@@ -180,14 +179,15 @@ class EstimateService implements EstimatePdfDataProvider
$estimateTemplate = Estimate::find($estimate->id)->template_name;
$company = Company::find($estimate->company_id);
$locale = CompanySetting::getSetting('language', $company->id);
$customFields = CustomField::where('model_type', 'Item')->get();
$language = CompanySetting::getSetting('language', $company->id);
$customFields = CustomField::query()->where('model_type', 'Item')->get();
App::setLocale($locale);
App::setLocale($language);
// Absent for a company that never uploaded one; the templates cope.
$logo = $company->logo_path;
view()->share([
View::share([
'estimate' => $estimate,
'customFields' => $customFields,
'logo' => $logo ?? null,
@@ -200,7 +200,10 @@ class EstimateService implements EstimatePdfDataProvider
$templatePath = PdfTemplateUtils::resolveView('estimate', $estimateTemplate, 'estimate1');
if (request()->has('preview')) {
// `?preview` hands back the raw HTML instead of a rendered PDF.
$wantsHtmlPreview = request()->has('preview');
if ($wantsHtmlPreview) {
return view($templatePath);
}
@@ -216,9 +219,9 @@ class EstimateService implements EstimatePdfDataProvider
$date = Carbon::now();
$serial = (new SerialNumberService)
->setModel($estimate)
->setCompany($estimate->company_id)
->setCustomer($estimate->customer_id)
->setModel($estimate)
->setNextNumbers();
$expiryDate = null;
@@ -264,9 +267,7 @@ class EstimateService implements EstimatePdfDataProvider
'base_sub_total' => $estimate->sub_total * $exchangeRate,
'base_tax' => $estimate->tax * $exchangeRate,
'base_due_amount' => $estimate->total * $exchangeRate,
'currency_id' => $estimate->currency_id,
'sales_tax_type' => $estimate->sales_tax_type,
'sales_tax_address_type' => $estimate->sales_tax_address_type,
...$estimate->only(['currency_id', 'sales_tax_type', 'sales_tax_address_type']),
]);
$newEstimate->unique_hash = Hashids::connection(HashidConnection::Estimate->value)->encode($newEstimate->id);
@@ -302,29 +303,41 @@ class EstimateService implements EstimatePdfDataProvider
$invoiceDate = Carbon::now();
$dueDate = null;
$dueDateEnabled = CompanySetting::getSetting(
'invoice_set_due_date_automatically',
$estimate->company_id
);
$autoDueDate = CompanySetting::getSetting('invoice_set_due_date_automatically', $estimate->company_id);
if ($dueDateEnabled === 'YES') {
$dueDateDays = intval(CompanySetting::getSetting(
'invoice_due_date_days',
$estimate->company_id
));
if ($autoDueDate === 'YES') {
$dueDateDays = (int) CompanySetting::getSetting('invoice_due_date_days', $estimate->company_id);
$dueDate = Carbon::now()->addDays($dueDateDays)->format('Y-m-d');
}
$serial = (new SerialNumberService)
->setModel(new Invoice)
->setCompany($estimate->company_id)
->setCustomer($estimate->customer_id)
->setSequenceScope(['type' => Invoice::TYPE_INVOICE])
->setModel(new Invoice)
->setNextNumbers();
$templateName = $estimate->getInvoiceTemplateName();
$invoiceTemplate = $estimate->getInvoiceTemplateName();
$exchangeRate = $estimate->exchange_rate;
// Columns the invoice inherits unchanged from the offer it settles.
$carriedOver = $estimate->only([
'customer_id',
'company_id',
'currency_id',
'sub_total',
'discount',
'discount_type',
'discount_val',
'tax',
'total',
'tax_per_item',
'discount_per_item',
'notes',
'sales_tax_type',
'sales_tax_address_type',
]);
$invoice = Invoice::create([
'creator_id' => Auth::id(),
'invoice_date' => $invoiceDate->format('Y-m-d'),
@@ -332,30 +345,19 @@ class EstimateService implements EstimatePdfDataProvider
'invoice_number' => $serial->getNextNumber(),
'sequence_number' => $serial->nextSequenceNumber,
'customer_sequence_number' => $serial->nextCustomerSequenceNumber,
// A second, independent rendering of the same number format rather
// than a copy of the number above.
'reference_number' => $serial->getNextNumber(),
'customer_id' => $estimate->customer_id,
'company_id' => $estimate->company_id,
'template_name' => $templateName,
'template_name' => $invoiceTemplate,
'status' => Invoice::STATUS_DRAFT,
'paid_status' => Invoice::STATUS_UNPAID,
'sub_total' => $estimate->sub_total,
'discount' => $estimate->discount,
'discount_type' => $estimate->discount_type,
'discount_val' => $estimate->discount_val,
'total' => $estimate->total,
'due_amount' => $estimate->total,
'tax_per_item' => $estimate->tax_per_item,
'discount_per_item' => $estimate->discount_per_item,
'tax' => $estimate->tax,
'notes' => $estimate->notes,
'exchange_rate' => $exchangeRate,
'base_discount_val' => $estimate->discount_val * $exchangeRate,
'base_sub_total' => $estimate->sub_total * $exchangeRate,
'base_total' => $estimate->total * $exchangeRate,
'base_tax' => $estimate->tax * $exchangeRate,
'currency_id' => $estimate->currency_id,
'sales_tax_type' => $estimate->sales_tax_type,
'sales_tax_address_type' => $estimate->sales_tax_address_type,
...$carriedOver,
]);
$invoice->unique_hash = Hashids::connection(HashidConnection::Invoice->value)->encode($invoice->id);
+102 -104
View File
@@ -21,10 +21,16 @@ use App\Platform\Pdf\Rendering\PdfTemplateUtils;
use App\Support\Hashids\HashidConnection;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\View;
use Illuminate\Validation\ValidationException;
class InvoiceService implements InvoicePdfDataProvider
{
/**
* Relations a single-document payload is always returned with.
*/
private const DETAIL_RELATIONS = ['items', 'items.fields', 'items.fields.customField', 'customer', 'taxes', 'creditNotes'];
public function __construct(
private readonly DocumentItemService $documentItemService,
private readonly CreditNoteService $creditNoteService,
@@ -48,14 +54,18 @@ class InvoiceService implements InvoicePdfDataProvider
$invoice = Invoice::create($attributes);
$serial = (new SerialNumberService)
->setModel($invoice)
->setCompany($invoice->company_id)
->setCustomer($invoice->customer_id)
->setSequenceScope(['type' => Invoice::TYPE_INVOICE])
->setModel($invoice)
->setNextNumbers();
$invoice->sequence_number = $serial->nextSequenceNumber;
$invoice->customer_sequence_number = $serial->nextCustomerSequenceNumber;
// Both sequences fall out of the same resolution pass. The visible
// number itself is rendered client-side and arrived with the payload.
$invoice->fill([
'sequence_number' => $serial->nextSequenceNumber,
'customer_sequence_number' => $serial->nextCustomerSequenceNumber,
]);
$invoice->unique_hash = Hashids::connection(HashidConnection::Invoice->value)->encode($invoice->id);
$invoice->save();
@@ -75,14 +85,7 @@ class InvoiceService implements InvoicePdfDataProvider
$this->customFieldValueWriter->attach($invoice, $customFields);
}
return Invoice::with([
'items',
'items.fields',
'items.fields.customField',
'customer',
'taxes',
'creditNotes',
])->findOrFail($invoice->id);
return Invoice::with(self::DETAIL_RELATIONS)->findOrFail($invoice->id);
}
/**
@@ -96,8 +99,8 @@ class InvoiceService implements InvoicePdfDataProvider
?iterable $customFields = null,
): Invoice {
$serial = (new SerialNumberService)
->setModel($invoice)
->setCompany($invoice->company_id)
->setModel($invoice)
->setCustomer($attributes['customer_id'])
->setSequenceScope(['type' => Invoice::TYPE_INVOICE])
->setModelObject($invoice->id)
@@ -142,13 +145,13 @@ class InvoiceService implements InvoicePdfDataProvider
$this->exchangeRateRecorder->record($invoice);
}
$invoice->items->map(function ($item) {
$fields = $item->fields()->get();
$fields->map(function ($field) {
$field->delete();
});
});
// Answers to item-level custom fields have no cascade of their own,
// so they are cleared row by row before the items are replaced.
foreach ($invoice->items as $lineItem) {
foreach ($lineItem->fields()->get() as $answer) {
$answer->delete();
}
}
$invoice->items()->delete();
$invoice->taxes()->delete();
@@ -163,14 +166,7 @@ class InvoiceService implements InvoicePdfDataProvider
$this->customFieldValueWriter->update($invoice, $customFields);
}
return Invoice::with([
'items',
'items.fields',
'items.fields.customField',
'customer',
'taxes',
'creditNotes',
])->findOrFail($invoice->id);
return Invoice::with(self::DETAIL_RELATIONS)->findOrFail($invoice->id);
}
public function delete(Collection $ids): bool
@@ -190,8 +186,10 @@ class InvoiceService implements InvoicePdfDataProvider
]);
}
if ($invoice->transactions()->exists()) {
$invoice->transactions()->delete();
$transactions = $invoice->transactions();
if ($transactions->exists()) {
$transactions->delete();
}
if ($invoice->isCreditNote() && $invoice->related_invoice_id && ! $ids->contains($invoice->related_invoice_id)) {
@@ -239,10 +237,7 @@ class InvoiceService implements InvoicePdfDataProvider
{
$data = $this->sendInvoiceData($invoice, $data);
return [
'type' => 'preview',
'view' => new SendInvoiceMail($data),
];
return ['type' => 'preview', 'view' => new SendInvoiceMail($data)];
}
public function send(Invoice $invoice, array $data): array
@@ -271,15 +266,15 @@ class InvoiceService implements InvoicePdfDataProvider
if ($invoice->tax_per_item === 'YES') {
foreach ($invoice->items as $item) {
foreach ($item->taxes as $tax) {
$found = $taxes->filter(function ($item) use ($tax) {
return $item->tax_type_id == $tax->tax_type_id;
})->first();
foreach ($item->taxes as $appliedTax) {
// Rows of one tax type collapse onto the first row seen for
// it, which then carries the running total for the document.
$running = $taxes->first(fn ($seen) => $seen->tax_type_id == $appliedTax->tax_type_id);
if ($found) {
$found->amount += $tax->amount;
if ($running) {
$running->amount += $appliedTax->amount;
} else {
$taxes->push($tax);
$taxes->push($appliedTax);
}
}
}
@@ -293,14 +288,15 @@ class InvoiceService implements InvoicePdfDataProvider
$invoice->loadMissing(['relatedInvoice', 'creditNotes']);
$company = Company::find($invoice->company_id);
$locale = CompanySetting::getSetting('language', $company->id);
$customFields = CustomField::where('model_type', 'Item')->get();
$language = CompanySetting::getSetting('language', $company->id);
$customFields = CustomField::query()->where('model_type', 'Item')->get();
App::setLocale($locale);
App::setLocale($language);
// Absent for a company that never uploaded one; the templates cope.
$logo = $company->logo_path;
view()->share([
View::share([
'invoice' => $invoice,
'customFields' => $customFields,
'company_address' => $invoice->getCompanyAddress(),
@@ -313,7 +309,10 @@ class InvoiceService implements InvoicePdfDataProvider
$templatePath = PdfTemplateUtils::resolveView('invoice', $invoiceTemplate, 'invoice1');
if (request()->has('preview')) {
// `?preview` hands back the raw HTML instead of a rendered PDF.
$wantsHtmlPreview = request()->has('preview');
if ($wantsHtmlPreview) {
return view($templatePath);
}
@@ -329,59 +328,59 @@ class InvoiceService implements InvoicePdfDataProvider
$date = Carbon::now();
$serial = (new SerialNumberService)
->setModel($invoice)
->setCompany($invoice->company_id)
->setCustomer($invoice->customer_id)
->setSequenceScope(['type' => Invoice::TYPE_INVOICE])
->setModel($invoice)
->setNextNumbers();
$dueDate = null;
$dueDateEnabled = CompanySetting::getSetting(
'invoice_set_due_date_automatically',
$invoice->company_id
);
$autoDueDate = CompanySetting::getSetting('invoice_set_due_date_automatically', $invoice->company_id);
if ($dueDateEnabled === 'YES') {
$dueDateDays = intval(CompanySetting::getSetting(
'invoice_due_date_days',
$invoice->company_id
));
if ($autoDueDate === 'YES') {
$dueDateDays = (int) CompanySetting::getSetting('invoice_due_date_days', $invoice->company_id);
$dueDate = Carbon::now()->addDays($dueDateDays)->format('Y-m-d');
}
$exchangeRate = $invoice->exchange_rate;
// Columns the copy inherits unchanged. Everything outside this list is
// either dated today, renumbered, or derived from the exchange rate.
$carriedOver = $invoice->only([
'reference_number',
'customer_id',
'company_id',
'template_name',
'currency_id',
'sub_total',
'discount',
'discount_type',
'discount_val',
'tax',
'total',
'tax_per_item',
'discount_per_item',
'notes',
'sales_tax_type',
'sales_tax_address_type',
]);
$newInvoice = Invoice::create([
'invoice_date' => $date->format('Y-m-d'),
'invoice_date' => $date->toDateString(),
'due_date' => $dueDate,
'invoice_number' => $serial->getNextNumber(),
'sequence_number' => $serial->nextSequenceNumber,
'customer_sequence_number' => $serial->nextCustomerSequenceNumber,
'reference_number' => $invoice->reference_number,
'customer_id' => $invoice->customer_id,
'company_id' => $invoice->company_id,
'template_name' => $invoice->template_name,
'status' => Invoice::STATUS_DRAFT,
'paid_status' => Invoice::STATUS_UNPAID,
'sub_total' => $invoice->sub_total,
'discount' => $invoice->discount,
'discount_type' => $invoice->discount_type,
'discount_val' => $invoice->discount_val,
'total' => $invoice->total,
'due_amount' => $invoice->total,
'tax_per_item' => $invoice->tax_per_item,
'discount_per_item' => $invoice->discount_per_item,
'tax' => $invoice->tax,
'notes' => $invoice->notes,
'exchange_rate' => $exchangeRate,
'base_total' => $invoice->total * $exchangeRate,
'base_discount_val' => $invoice->discount_val * $exchangeRate,
'base_sub_total' => $invoice->sub_total * $exchangeRate,
'base_tax' => $invoice->tax * $exchangeRate,
'base_due_amount' => $invoice->total * $exchangeRate,
'currency_id' => $invoice->currency_id,
'sales_tax_type' => $invoice->sales_tax_type,
'sales_tax_address_type' => $invoice->sales_tax_address_type,
...$carriedOver,
]);
$newInvoice->unique_hash = Hashids::connection(HashidConnection::Invoice->value)->encode($newInvoice->id);
@@ -395,14 +394,10 @@ class InvoiceService implements InvoicePdfDataProvider
}
if ($invoice->fields()->exists()) {
$customFields = [];
foreach ($invoice->fields as $data) {
$customFields[] = [
'id' => $data->custom_field_id,
'value' => $data->defaultAnswer,
];
}
$customFields = $invoice->fields->map(fn ($answer) => [
'id' => $answer->custom_field_id,
'value' => $answer->defaultAnswer,
])->all();
$this->customFieldValueWriter->attach($newInvoice, $customFields);
}
@@ -415,42 +410,49 @@ class InvoiceService implements InvoicePdfDataProvider
$invoice->load(['items', 'items.taxes', 'customer', 'taxes']);
$serial = (new SerialNumberService)
->setModel(new Estimate)
->setCompany($invoice->company_id)
->setCustomer($invoice->customer_id)
->setModel(new Estimate)
->setNextNumbers();
$exchangeRate = $invoice->exchange_rate;
// Columns the offer inherits unchanged from the document it replaces.
$carriedOver = $invoice->only([
'creator_id',
'customer_id',
'company_id',
'currency_id',
'sub_total',
'discount',
'discount_type',
'discount_val',
'tax',
'total',
'tax_per_item',
'discount_per_item',
'notes',
'sales_tax_type',
'sales_tax_address_type',
]);
$estimate = Estimate::create([
'creator_id' => $invoice->creator_id,
'estimate_date' => Carbon::now()->format('Y-m-d'),
'expiry_date' => Carbon::now()->addDays(30)->format('Y-m-d'),
'estimate_number' => $serial->getNextNumber(),
'sequence_number' => $serial->nextSequenceNumber,
'customer_sequence_number' => $serial->nextCustomerSequenceNumber,
// A second, independent rendering of the same number format rather
// than a copy of the number above.
'reference_number' => $serial->getNextNumber(),
'customer_id' => $invoice->customer_id,
'company_id' => $invoice->company_id,
'template_name' => $invoice->getEstimateTemplateName(),
'status' => Estimate::STATUS_DRAFT,
'sub_total' => $invoice->sub_total,
'discount' => $invoice->discount,
'discount_type' => $invoice->discount_type,
'discount_val' => $invoice->discount_val,
'total' => $invoice->total,
'tax_per_item' => $invoice->tax_per_item,
'discount_per_item' => $invoice->discount_per_item,
'tax' => $invoice->tax,
'notes' => $invoice->notes,
'exchange_rate' => $exchangeRate,
'base_discount_val' => $invoice->discount_val * $exchangeRate,
'base_sub_total' => $invoice->sub_total * $exchangeRate,
'base_total' => $invoice->total * $exchangeRate,
'base_tax' => $invoice->tax * $exchangeRate,
'currency_id' => $invoice->currency_id,
'sales_tax_type' => $invoice->sales_tax_type,
'sales_tax_address_type' => $invoice->sales_tax_address_type,
...$carriedOver,
]);
$estimate->unique_hash = Hashids::connection(HashidConnection::Estimate->value)->encode($estimate->id);
@@ -463,14 +465,10 @@ class InvoiceService implements InvoicePdfDataProvider
}
if ($invoice->fields()->exists()) {
$customFields = [];
foreach ($invoice->fields as $data) {
$customFields[] = [
'id' => $data->custom_field_id,
'value' => $data->defaultAnswer,
];
}
$customFields = $invoice->fields->map(fn ($answer) => [
'id' => $answer->custom_field_id,
'value' => $answer->defaultAnswer,
])->all();
$this->customFieldValueWriter->attach($estimate, $customFields);
}
@@ -90,12 +90,18 @@ class RecurringInvoiceService
foreach ($ids as $id) {
$recurringInvoice = RecurringInvoice::find($id);
if ($recurringInvoice->invoices()->exists()) {
$recurringInvoice->invoices()->update(['recurring_invoice_id' => null]);
// Invoices already generated outlive their template; all they lose
// is the link back to it.
$generated = $recurringInvoice->invoices();
if ($generated->exists()) {
$generated->update(['recurring_invoice_id' => null]);
}
if ($recurringInvoice->items()->exists()) {
$recurringInvoice->items()->delete();
$lineItems = $recurringInvoice->items();
if ($lineItems->exists()) {
$lineItems->delete();
}
if ($recurringInvoice->taxes()->exists()) {
@@ -155,8 +161,8 @@ class RecurringInvoiceService
}
$newInvoice['creator_id'] = $recurringInvoice->creator_id;
$newInvoice['invoice_date'] = Carbon::today()->format('Y-m-d');
$newInvoice['due_date'] = Carbon::today()->addDays($days)->format('Y-m-d');
$newInvoice['invoice_date'] = Carbon::today()->toDateString();
$newInvoice['due_date'] = Carbon::today()->addDays($days)->toDateString();
$newInvoice['status'] = Invoice::STATUS_DRAFT;
$newInvoice['company_id'] = $recurringInvoice->company_id;
$newInvoice['paid_status'] = Invoice::STATUS_UNPAID;
@@ -178,14 +184,20 @@ class RecurringInvoiceService
$newInvoice['exchange_rate'] = $recurringInvoice->exchange_rate;
$newInvoice['sales_tax_type'] = $recurringInvoice->sales_tax_type;
$newInvoice['sales_tax_address_type'] = $recurringInvoice->sales_tax_address_type;
$newInvoice['invoice_number'] = $serial->getNextNumber();
$newInvoice['sequence_number'] = $serial->nextSequenceNumber;
$newInvoice['customer_sequence_number'] = $serial->nextCustomerSequenceNumber;
$newInvoice['base_due_amount'] = $recurringInvoice->exchange_rate * $recurringInvoice->due_amount;
$newInvoice['base_discount_val'] = $recurringInvoice->exchange_rate * $recurringInvoice->discount_val;
$newInvoice['base_sub_total'] = $recurringInvoice->exchange_rate * $recurringInvoice->sub_total;
$newInvoice['base_tax'] = $recurringInvoice->exchange_rate * $recurringInvoice->tax;
$newInvoice['base_total'] = $recurringInvoice->exchange_rate * $recurringInvoice->total;
// Stamped last: the visible number is rendered from a format that may
// embed either of the two sequences.
$newInvoice += [
'invoice_number' => $serial->getNextNumber(),
'sequence_number' => $serial->nextSequenceNumber,
'customer_sequence_number' => $serial->nextCustomerSequenceNumber,
];
$invoice = Invoice::create($newInvoice);
$invoice->unique_hash = Hashids::connection(HashidConnection::Invoice->value)->encode($invoice->id);
$invoice->save();
@@ -200,24 +212,23 @@ class RecurringInvoiceService
if ($recurringInvoice->fields()->exists()) {
$customField = [];
foreach ($recurringInvoice->fields as $data) {
$customField[] = [
'id' => $data->custom_field_id,
'value' => $data->defaultAnswer,
];
foreach ($recurringInvoice->fields as $answer) {
$customField[] = ['id' => $answer->custom_field_id, 'value' => $answer->defaultAnswer];
}
$this->customFieldValueWriter->attach($invoice, $customField);
}
if ($recurringInvoice->send_automatically == true) {
$customer = $invoice->customer;
$data = [
'body' => CompanySetting::getSetting('invoice_mail_body', $recurringInvoice->company_id),
'from' => config('mail.from.address'),
'to' => $recurringInvoice->customer->email,
'subject' => trans('invoices')['new_invoice'],
'invoice' => $invoice->toArray(),
'customer' => $invoice->customer->toArray(),
'customer' => $customer->toArray(),
'company' => Company::find($invoice->company_id),
];
@@ -245,12 +256,14 @@ class RecurringInvoiceService
}
}
/**
* Write the template's own tax rows, skipping the ones carrying no amount.
*/
private function createTaxes(RecurringInvoice $recurringInvoice, array $taxes): void
{
foreach ($taxes as $tax) {
$tax['company_id'] = $recurringInvoice->company_id;
if (gettype($tax['amount']) !== 'NULL') {
$tax['company_id'] = $recurringInvoice->company_id;
$recurringInvoice->taxes()->create($tax);
}
}
@@ -21,6 +21,11 @@ use Illuminate\Http\Request;
use Illuminate\Mail\Markdown;
use Illuminate\Validation\ValidationException;
/**
* Company-scoped invoice endpoints: listing, the write surface, bulk removal,
* mailing, cloning, conversion to an estimate, credit notes, and the status
* transitions.
*/
class InvoicesController extends Controller
{
public function __construct(
@@ -29,7 +34,7 @@ class InvoicesController extends Controller
) {}
/**
* Display a listing of the resource.
* Paginated invoices of the active company, newest first.
*
* @return JsonResponse
*/
@@ -38,23 +43,28 @@ class InvoicesController extends Controller
$this->authorize('viewAny', Invoice::class);
$limit = $request->input('limit', 10);
$filters = $request->all();
// creditNotes drives the "cancelled" badge on every row, so it is
// eager-loaded (two columns) rather than probed per row.
$invoices = Invoice::whereCompany()
->applyFilters($request->all())
$invoices = Invoice::query()
->whereCompany()
->applyFilters($filters)
->with(['customer', 'creditNotes:id,related_invoice_id,invoice_number,total'])
->latest()
->paginateData($limit);
return InvoiceResource::collection($invoices)
->additional(['meta' => [
'invoice_total_count' => Invoice::whereCompany()->count(),
]]);
->additional([
'meta' => [
'invoice_total_count' => Invoice::query()->whereCompany()->count(),
],
]);
}
/**
* Store a newly created resource in storage.
* Persist a new invoice, optionally mail it straight away, and queue its
* PDF render.
*
* @param Request $request
* @return JsonResponse
@@ -70,17 +80,17 @@ class InvoicesController extends Controller
customFields: $this->customFields($request),
);
if ($request->has('invoiceSend')) {
if ($request->exists('invoiceSend')) {
$this->invoiceService->send($invoice, $request->only(['subject', 'body']));
}
GenerateInvoicePdfJob::dispatch($invoice);
dispatch(new GenerateInvoicePdfJob($invoice));
return new InvoiceResource($invoice);
return InvoiceResource::make($invoice);
}
/**
* Display the specified resource.
* One invoice, loaded with what its detail page reads.
*
* @return JsonResponse
*/
@@ -103,7 +113,7 @@ class InvoicesController extends Controller
}
/**
* Update the specified resource in storage.
* Overwrite an invoice, lines and taxes included, and re-render its PDF.
*
* @param Request $request
* @return JsonResponse
@@ -120,13 +130,13 @@ class InvoicesController extends Controller
customFields: $this->customFields($request),
);
GenerateInvoicePdfJob::dispatch($invoice, true);
dispatch(new GenerateInvoicePdfJob($invoice, true));
return new InvoiceResource($invoice);
return InvoiceResource::make($invoice);
}
/**
* delete the specified resources in storage.
* Bulk removal. Ids outside the active company are silently skipped.
*
* @param Request $request
* @return JsonResponse
@@ -141,9 +151,7 @@ class InvoicesController extends Controller
$this->invoiceService->delete($ids);
return response()->json([
'success' => true,
]);
return response()->json(['success' => true]);
}
public function send(SendInvoiceRequest $request, Invoice $invoice)
@@ -152,19 +160,17 @@ class InvoicesController extends Controller
$this->invoiceService->send($invoice, $request->all());
return response()->json([
'success' => true,
]);
return response()->json(['success' => true]);
}
public function sendPreview(SendInvoiceRequest $request, Invoice $invoice)
{
$this->authorize('send invoice', $invoice);
$markdown = new Markdown(view(), config('mail.markdown'));
$markdown = new Markdown(app('view'), config('mail.markdown'));
$data = $this->invoiceService->sendInvoiceData($invoice, $request->all());
$data['url'] = $invoice->invoicePdfUrl;
$data['url'] = $invoice->invoice_pdf_url;
// Preview the template that will actually be sent: a credit note goes
// out through SendCreditNoteMail, so it must preview as one.
@@ -257,9 +263,7 @@ class InvoicesController extends Controller
$this->invoiceService->changeStatus($invoice, $request->status);
return response()->json([
'success' => true,
]);
return response()->json(['success' => true]);
}
private function customFields(InvoicesRequest $request): ?iterable
@@ -18,19 +18,23 @@ class DocumentPdfController extends Controller
public function invoice(Request $request, Invoice $invoice)
{
if ($request->has('preview')) {
if ($request->exists('preview')) {
return $this->invoiceService->getPdfData($invoice);
}
return $invoice->getGeneratedPDFOrStream('invoice');
$pdf = $invoice->getGeneratedPDFOrStream('invoice');
return $pdf;
}
public function estimate(Request $request, Estimate $estimate)
{
if ($request->has('preview')) {
if ($request->exists('preview')) {
return $this->estimateService->getPdfData($estimate);
}
return $estimate->getGeneratedPDFOrStream('estimate');
$pdf = $estimate->getGeneratedPDFOrStream('estimate');
return $pdf;
}
}
@@ -8,7 +8,7 @@ use Illuminate\Validation\Rule;
class CreateCreditNoteRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
* Whether this request may proceed at all.
*
* The controller authorizes the ability against the invoice being credited.
*/
@@ -18,7 +18,7 @@ class CreateCreditNoteRequest extends FormRequest
}
/**
* Get the validation rules that apply to the request.
* The shape a credit-note payload has to satisfy.
*
* "items" is optional: an absent or empty list credits every remaining
* quantity of the invoice, which is the full reversal. Each supplied line
+22 -9
View File
@@ -18,19 +18,32 @@ Route::post('/invoices/{invoice}/clone', [InvoicesController::class, 'clone']);
Route::post('/invoices/{invoice}/convert-to-estimate', [InvoicesController::class, 'convertToEstimate']);
Route::post('/invoices/{invoice}/credit-note', [InvoicesController::class, 'createCreditNote']);
Route::post('/invoices/{invoice}/status', [InvoicesController::class, 'changeStatus']);
Route::post('/invoices/delete', [InvoicesController::class, 'delete']);
Route::get('/invoices/templates', InvoiceTemplatesController::class);
Route::apiResource('invoices', InvoicesController::class);
Route::get('/recurring-invoice-frequency', RecurringInvoiceFrequencyController::class);
Route::post('/recurring-invoices/delete', [RecurringInvoiceController::class, 'delete']);
Route::apiResource('recurring-invoices', RecurringInvoiceController::class);
// Two collection-level endpoints that are not resource verbs. Both are
// declared ahead of the resource, so neither literal segment can ever be
// read as an {invoice} key.
Route::prefix('invoices')->group(function (): void {
Route::post('delete', [InvoicesController::class, 'delete']);
Route::get('templates', InvoiceTemplatesController::class);
});
Route::apiResources(['invoices' => InvoicesController::class]);
// Recurring templates: first the fixed cron presets the editor offers, then
// the same bulk-delete-before-the-resource ordering.
Route::get('recurring-invoice-frequency', RecurringInvoiceFrequencyController::class);
Route::post('recurring-invoices/delete', [RecurringInvoiceController::class, 'delete']);
Route::apiResources(['recurring-invoices' => RecurringInvoiceController::class]);
Route::get('/estimates/{estimate}/send/preview', [EstimatesController::class, 'sendPreview']);
Route::post('/estimates/{estimate}/send', [EstimatesController::class, 'send']);
Route::post('/estimates/{estimate}/clone', [EstimatesController::class, 'clone']);
Route::post('/estimates/{estimate}/status', [EstimatesController::class, 'changeStatus']);
Route::post('/estimates/{estimate}/convert-to-invoice', [EstimatesController::class, 'convertToInvoice']);
Route::get('/estimates/templates', EstimateTemplatesController::class);
Route::post('/estimates/delete', [EstimatesController::class, 'delete']);
Route::apiResource('estimates', EstimatesController::class);
// As for invoices, ahead of the resource — here the templates listing comes
// first, which is the order this file has always used for offers.
Route::prefix('estimates')->group(function (): void {
Route::get('templates', EstimateTemplatesController::class);
Route::post('delete', [EstimatesController::class, 'delete']);
});
Route::apiResources(['estimates' => EstimatesController::class]);