mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-01 12:51:00 +00:00
refactor(sales): rewrite legacy-era regions of the document services, controllers, credit-note request and routes in place
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user