diff --git a/app/Domains/Sales/Application/EstimateService.php b/app/Domains/Sales/Application/EstimateService.php index db12be48..fd3a1019 100644 --- a/app/Domains/Sales/Application/EstimateService.php +++ b/app/Domains/Sales/Application/EstimateService.php @@ -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); diff --git a/app/Domains/Sales/Application/InvoiceService.php b/app/Domains/Sales/Application/InvoiceService.php index 0262b723..9d107cb5 100644 --- a/app/Domains/Sales/Application/InvoiceService.php +++ b/app/Domains/Sales/Application/InvoiceService.php @@ -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); } diff --git a/app/Domains/Sales/Application/RecurringInvoiceService.php b/app/Domains/Sales/Application/RecurringInvoiceService.php index c1dcd85d..e19b86dd 100644 --- a/app/Domains/Sales/Application/RecurringInvoiceService.php +++ b/app/Domains/Sales/Application/RecurringInvoiceService.php @@ -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); } } diff --git a/app/Domains/Sales/Http/Controllers/Company/InvoicesController.php b/app/Domains/Sales/Http/Controllers/Company/InvoicesController.php index 79ed6b37..651d1a10 100644 --- a/app/Domains/Sales/Http/Controllers/Company/InvoicesController.php +++ b/app/Domains/Sales/Http/Controllers/Company/InvoicesController.php @@ -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 diff --git a/app/Domains/Sales/Http/Controllers/DocumentPdfController.php b/app/Domains/Sales/Http/Controllers/DocumentPdfController.php index d2f3d0e0..43c74482 100644 --- a/app/Domains/Sales/Http/Controllers/DocumentPdfController.php +++ b/app/Domains/Sales/Http/Controllers/DocumentPdfController.php @@ -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; } } diff --git a/app/Domains/Sales/Http/Requests/CreateCreditNoteRequest.php b/app/Domains/Sales/Http/Requests/CreateCreditNoteRequest.php index 7efbbd04..195a09c7 100644 --- a/app/Domains/Sales/Http/Requests/CreateCreditNoteRequest.php +++ b/app/Domains/Sales/Http/Requests/CreateCreditNoteRequest.php @@ -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 diff --git a/app/Domains/Sales/routes/company.php b/app/Domains/Sales/routes/company.php index 16ce67a0..dbe258d6 100644 --- a/app/Domains/Sales/routes/company.php +++ b/app/Domains/Sales/routes/company.php @@ -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]);