diff --git a/app/Domains/Sales/Application/EstimateService.php b/app/Domains/Sales/Application/EstimateService.php new file mode 100644 index 00000000..fd3a1019 --- /dev/null +++ b/app/Domains/Sales/Application/EstimateService.php @@ -0,0 +1,394 @@ + $attributes + * @param array> $items + * @param array>|null $taxes + */ + public function create( + array $attributes, + array $items, + ?array $taxes = null, + ?iterable $customFields = null, + ): Estimate { + $estimate = Estimate::create($attributes); + $estimate->unique_hash = Hashids::connection(HashidConnection::Estimate->value)->encode($estimate->id); + $serial = (new SerialNumberService) + ->setCompany($estimate->company_id) + ->setCustomer($estimate->customer_id) + ->setModel($estimate) + ->setNextNumbers(); + + // 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); + + if ((string) $attributes['currency_id'] !== $companyCurrency) { + $this->exchangeRateRecorder->record($estimate); + } + + $this->documentItemService->createItems($estimate, $items); + + if ($taxes) { + $this->documentItemService->createTaxes($estimate, $taxes); + } + + if ($customFields) { + $this->customFieldValueWriter->attach($estimate, $customFields); + } + + return $estimate; + } + + public function update( + Estimate $estimate, + array $attributes, + array $items, + ?array $taxes = null, + ?iterable $customFields = null, + ): Estimate { + $serial = (new SerialNumberService) + ->setCompany($estimate->company_id) + ->setModel($estimate) + ->setCustomer($attributes['customer_id']) + ->setModelObject($estimate->id) + ->setNextNumbers(); + + $attributes['customer_sequence_number'] = $serial->nextCustomerSequenceNumber; + + $estimate->update($attributes); + + $companyCurrency = CompanySetting::getSetting('currency', $estimate->company_id); + + if ((string) $attributes['currency_id'] !== $companyCurrency) { + $this->exchangeRateRecorder->record($estimate); + } + + // 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(); + + $this->documentItemService->createItems($estimate, $items); + + if ($taxes) { + $this->documentItemService->createTaxes($estimate, $taxes); + } + + if ($customFields) { + $this->customFieldValueWriter->update($estimate, $customFields); + } + + return Estimate::with(['items.taxes', 'items.fields', 'items.fields.customField', 'customer', 'taxes']) + ->findOrFail($estimate->id); + } + + public function sendEstimateData(Estimate $estimate, array $data): array + { + $data['estimate'] = $estimate->toArray(); + $data['user'] = $estimate->customer->toArray(); + $data['company'] = $estimate->company->toArray(); + $data['body'] = $estimate->getEmailBody($data['body']); + $data['attach']['data'] = ($estimate->getEmailAttachmentSetting()) ? $this->getPdfData($estimate) : null; + + return $data; + } + + public function send(Estimate $estimate, array $data): array + { + $data = $this->sendEstimateData($estimate, $data); + + $this->mailConfigurator->applyCompanyConfig($estimate->company_id); + + if ($estimate->status == Estimate::STATUS_DRAFT) { + $estimate->status = Estimate::STATUS_SENT; + $estimate->save(); + } + + $this->estimateEmailSender->send($data); + + return [ + 'success' => true, + 'type' => 'send', + ]; + } + + public function getPdfData(Estimate $estimate): mixed + { + $taxes = collect(); + + if ($estimate->tax_per_item === 'YES') { + foreach ($estimate->items as $item) { + 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 ($running) { + $running->amount += $appliedTax->amount; + } else { + $taxes->push($appliedTax); + } + } + } + } + + $estimateTemplate = Estimate::find($estimate->id)->template_name; + + $company = Company::find($estimate->company_id); + $language = CompanySetting::getSetting('language', $company->id); + $customFields = CustomField::query()->where('model_type', 'Item')->get(); + + App::setLocale($language); + + // Absent for a company that never uploaded one; the templates cope. + $logo = $company->logo_path; + + View::share([ + 'estimate' => $estimate, + 'customFields' => $customFields, + 'logo' => $logo ?? null, + 'company_address' => $estimate->getCompanyAddress(), + 'shipping_address' => $estimate->getCustomerShippingAddress(), + 'billing_address' => $estimate->getCustomerBillingAddress(), + 'notes' => $estimate->getNotes(), + 'taxes' => $taxes, + ]); + + $templatePath = PdfTemplateUtils::resolveView('estimate', $estimateTemplate, 'estimate1'); + + // `?preview` hands back the raw HTML instead of a rendered PDF. + $wantsHtmlPreview = request()->has('preview'); + + if ($wantsHtmlPreview) { + return view($templatePath); + } + + return Pdf::loadView($templatePath, PdfMetadata::forDocument( + __('pdf_estimate_label'), + $estimate->estimate_number, + $company, + )); + } + + public function clone(Estimate $estimate): Estimate + { + $date = Carbon::now(); + + $serial = (new SerialNumberService) + ->setCompany($estimate->company_id) + ->setCustomer($estimate->customer_id) + ->setModel($estimate) + ->setNextNumbers(); + + $expiryDate = null; + $expiryEnabled = CompanySetting::getSetting( + 'estimate_set_expiry_date_automatically', + $estimate->company_id + ); + + if ($expiryEnabled === 'YES') { + $expiryDays = intval(CompanySetting::getSetting( + 'estimate_expiry_date_days', + $estimate->company_id + )); + $expiryDate = Carbon::now()->addDays($expiryDays)->format('Y-m-d'); + } + + $exchangeRate = $estimate->exchange_rate; + + $newEstimate = Estimate::create([ + 'estimate_date' => $date->format('Y-m-d'), + 'expiry_date' => $expiryDate, + 'estimate_number' => $serial->getNextNumber(), + 'sequence_number' => $serial->nextSequenceNumber, + 'customer_sequence_number' => $serial->nextCustomerSequenceNumber, + 'reference_number' => $estimate->reference_number, + 'customer_id' => $estimate->customer_id, + 'company_id' => $estimate->company_id, + 'template_name' => $estimate->template_name, + 'status' => Estimate::STATUS_DRAFT, + '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_total' => $estimate->total * $exchangeRate, + 'base_discount_val' => $estimate->discount_val * $exchangeRate, + 'base_sub_total' => $estimate->sub_total * $exchangeRate, + 'base_tax' => $estimate->tax * $exchangeRate, + 'base_due_amount' => $estimate->total * $exchangeRate, + ...$estimate->only(['currency_id', 'sales_tax_type', 'sales_tax_address_type']), + ]); + + $newEstimate->unique_hash = Hashids::connection(HashidConnection::Estimate->value)->encode($newEstimate->id); + $newEstimate->save(); + + $estimate->load('items.taxes'); + $this->documentItemService->createItems($newEstimate, $estimate->items->toArray()); + + if ($estimate->taxes) { + $this->documentItemService->createTaxes($newEstimate, $estimate->taxes->toArray()); + } + + if ($estimate->fields()->exists()) { + $customFields = []; + + foreach ($estimate->fields as $data) { + $customFields[] = [ + 'id' => $data->custom_field_id, + 'value' => $data->defaultAnswer, + ]; + } + + $this->customFieldValueWriter->attach($newEstimate, $customFields); + } + + return $newEstimate; + } + + public function convertToInvoice(Estimate $estimate): Invoice + { + $estimate->load(['items', 'items.taxes', 'customer', 'taxes']); + + $invoiceDate = Carbon::now(); + $dueDate = null; + + $autoDueDate = CompanySetting::getSetting('invoice_set_due_date_automatically', $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) + ->setCompany($estimate->company_id) + ->setCustomer($estimate->customer_id) + ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) + ->setModel(new Invoice) + ->setNextNumbers(); + + $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'), + 'due_date' => $dueDate, + '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(), + 'template_name' => $invoiceTemplate, + 'status' => Invoice::STATUS_DRAFT, + 'paid_status' => Invoice::STATUS_UNPAID, + 'due_amount' => $estimate->total, + '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, + ...$carriedOver, + ]); + + $invoice->unique_hash = Hashids::connection(HashidConnection::Invoice->value)->encode($invoice->id); + $invoice->save(); + + $this->documentItemService->createItems($invoice, $estimate->items->toArray()); + + if ($estimate->taxes) { + $this->documentItemService->createTaxes($invoice, $estimate->taxes->toArray()); + } + + if ($estimate->fields()->exists()) { + $customFields = []; + + foreach ($estimate->fields as $data) { + $customFields[] = [ + 'id' => $data->custom_field_id, + 'value' => $data->defaultAnswer, + ]; + } + + $this->customFieldValueWriter->attach($invoice, $customFields); + } + + $estimate->checkForEstimateConvertAction(); + + return Invoice::find($invoice->id); + } + + public function changeStatus(Estimate $estimate, string $status): void + { + $estimate->update(['status' => $status]); + } +} diff --git a/app/Domains/Sales/Application/InvoiceService.php b/app/Domains/Sales/Application/InvoiceService.php new file mode 100644 index 00000000..9d107cb5 --- /dev/null +++ b/app/Domains/Sales/Application/InvoiceService.php @@ -0,0 +1,503 @@ + $attributes + * @param array> $items + * @param array>|null $taxes + */ + public function create( + array $attributes, + array $items, + ?array $taxes = null, + ?iterable $customFields = null, + ): Invoice { + $invoice = Invoice::create($attributes); + + $serial = (new SerialNumberService) + ->setCompany($invoice->company_id) + ->setCustomer($invoice->customer_id) + ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) + ->setModel($invoice) + ->setNextNumbers(); + + // 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(); + + $this->documentItemService->createItems($invoice, $items); + + $companyCurrency = CompanySetting::getSetting('currency', $invoice->company_id); + + if ((string) $attributes['currency_id'] !== $companyCurrency) { + $this->exchangeRateRecorder->record($invoice); + } + + if ($taxes) { + $this->documentItemService->createTaxes($invoice, $taxes); + } + + if ($customFields) { + $this->customFieldValueWriter->attach($invoice, $customFields); + } + + return Invoice::with(self::DETAIL_RELATIONS)->findOrFail($invoice->id); + } + + /** + * @throws ValidationException + */ + public function update( + Invoice $invoice, + array $attributes, + array $items, + ?array $taxes = null, + ?iterable $customFields = null, + ): Invoice { + $serial = (new SerialNumberService) + ->setCompany($invoice->company_id) + ->setModel($invoice) + ->setCustomer($attributes['customer_id']) + ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) + ->setModelObject($invoice->id) + ->setNextNumbers(); + + $oldTotal = $invoice->total; + + $totalPaidAmount = $invoice->total - $invoice->due_amount; + + if ($totalPaidAmount > 0 && (int) $invoice->customer_id !== (int) $attributes['customer_id']) { + throw ValidationException::withMessages([ + 'customer_id' => ['customer_cannot_be_changed_after_payment_is_added'], + ]); + } + + if ($attributes['total'] >= 0 && $attributes['total'] < $totalPaidAmount) { + throw ValidationException::withMessages([ + 'total' => ['total_invoice_amount_must_be_more_than_paid_amount'], + ]); + } + + if ($oldTotal != $attributes['total']) { + $oldTotal = (int) round($attributes['total']) - (int) $oldTotal; + } else { + $oldTotal = 0; + } + + $attributes['due_amount'] = ($invoice->due_amount + $oldTotal); + $attributes['base_due_amount'] = $attributes['due_amount'] * $attributes['exchange_rate']; + $attributes['customer_sequence_number'] = $serial->nextCustomerSequenceNumber; + + $invoice->update($attributes); + + $statusData = $invoice->getInvoiceStatusByAmount($attributes['due_amount']); + if (! empty($statusData)) { + $invoice->update($statusData); + } + + $companyCurrency = CompanySetting::getSetting('currency', $invoice->company_id); + + if ((string) $attributes['currency_id'] !== $companyCurrency) { + $this->exchangeRateRecorder->record($invoice); + } + + // 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(); + + $this->documentItemService->createItems($invoice, $items); + + if ($taxes) { + $this->documentItemService->createTaxes($invoice, $taxes); + } + + if ($customFields) { + $this->customFieldValueWriter->update($invoice, $customFields); + } + + return Invoice::with(self::DETAIL_RELATIONS)->findOrFail($invoice->id); + } + + public function delete(Collection $ids): bool + { + // Invoices that lose a credit note in this batch and survive it. Their + // balances are recomputed once, after every deletion has landed, so a + // batch deleting several credit notes of the same invoice settles on + // the right figure instead of one per deleted document. + $creditedInvoiceIds = []; + + foreach ($ids as $id) { + $invoice = Invoice::find($id); + + if ($invoice->allocations()->exists()) { + throw ValidationException::withMessages([ + 'invoice' => ['invoice_has_payment_allocations'], + ]); + } + + $transactions = $invoice->transactions(); + + if ($transactions->exists()) { + $transactions->delete(); + } + + if ($invoice->isCreditNote() && $invoice->related_invoice_id && ! $ids->contains($invoice->related_invoice_id)) { + $creditedInvoiceIds[$invoice->related_invoice_id] = $invoice->related_invoice_id; + } + + $invoice->delete(); + } + + // There is no DB-level foreign key on related_invoice_id by convention, + // so the cascade lives here: nothing that survives the batch may keep + // pointing at a row that just went away. + Invoice::whereIn('related_invoice_id', $ids)->update(['related_invoice_id' => null]); + + // Deleting a credit note gives back the amount it had credited off its + // original invoice (mirror of the create-side adjustment; same symmetry + // PR #536 implemented). The balance is recomputed from the payments and + // the credit notes that remain rather than restored from a snapshot, so + // it is exact whether the invoice was partly paid, partly credited, or + // both. + foreach ($creditedInvoiceIds as $creditedInvoiceId) { + $original = Invoice::find($creditedInvoiceId); + + if ($original) { + $this->creditNoteService->recalculateBalance($original); + } + } + + return true; + } + + public function sendInvoiceData(Invoice $invoice, array $data): array + { + $data['invoice'] = $invoice->toArray(); + $data['customer'] = $invoice->customer->toArray(); + $data['company'] = Company::find($invoice->company_id); + $data['subject'] = $invoice->getEmailString($data['subject']); + $data['body'] = $invoice->getEmailString($data['body']); + $data['attach']['data'] = ($invoice->getEmailAttachmentSetting()) ? $this->getPdfData($invoice) : null; + + return $data; + } + + public function preview(Invoice $invoice, array $data): array + { + $data = $this->sendInvoiceData($invoice, $data); + + return ['type' => 'preview', 'view' => new SendInvoiceMail($data)]; + } + + public function send(Invoice $invoice, array $data): array + { + $data = $this->sendInvoiceData($invoice, $data); + + $this->mailConfigurator->applyCompanyConfig($invoice->company_id); + + $this->invoiceEmailSender->send($data, $invoice->isCreditNote()); + + if ($invoice->status == Invoice::STATUS_DRAFT) { + $invoice->status = Invoice::STATUS_SENT; + $invoice->sent = true; + $invoice->save(); + } + + return [ + 'success' => true, + 'type' => 'send', + ]; + } + + public function getPdfData(Invoice $invoice): mixed + { + $taxes = collect(); + + if ($invoice->tax_per_item === 'YES') { + foreach ($invoice->items as $item) { + 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 ($running) { + $running->amount += $appliedTax->amount; + } else { + $taxes->push($appliedTax); + } + } + } + } + + $invoiceTemplate = Invoice::find($invoice->id)->template_name; + + // Cheap either way: relatedInvoice is null for regular invoices and + // creditNotes is empty for credit notes. Eager-loaded here so the + // invoice templates can reference the paired document. + $invoice->loadMissing(['relatedInvoice', 'creditNotes']); + + $company = Company::find($invoice->company_id); + $language = CompanySetting::getSetting('language', $company->id); + $customFields = CustomField::query()->where('model_type', 'Item')->get(); + + App::setLocale($language); + + // Absent for a company that never uploaded one; the templates cope. + $logo = $company->logo_path; + + View::share([ + 'invoice' => $invoice, + 'customFields' => $customFields, + 'company_address' => $invoice->getCompanyAddress(), + 'shipping_address' => $invoice->getCustomerShippingAddress(), + 'billing_address' => $invoice->getCustomerBillingAddress(), + 'notes' => $invoice->getNotes(), + 'logo' => $logo ?? null, + 'taxes' => $taxes, + ]); + + $templatePath = PdfTemplateUtils::resolveView('invoice', $invoiceTemplate, 'invoice1'); + + // `?preview` hands back the raw HTML instead of a rendered PDF. + $wantsHtmlPreview = request()->has('preview'); + + if ($wantsHtmlPreview) { + return view($templatePath); + } + + return Pdf::loadView($templatePath, PdfMetadata::forDocument( + __($invoice->isCreditNote() ? 'pdf_credit_note_label' : 'pdf_invoice_label'), + $invoice->invoice_number, + $company, + )); + } + + public function clone(Invoice $invoice): Invoice + { + $date = Carbon::now(); + + $serial = (new SerialNumberService) + ->setCompany($invoice->company_id) + ->setCustomer($invoice->customer_id) + ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) + ->setModel($invoice) + ->setNextNumbers(); + + $dueDate = null; + $autoDueDate = CompanySetting::getSetting('invoice_set_due_date_automatically', $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->toDateString(), + 'due_date' => $dueDate, + 'invoice_number' => $serial->getNextNumber(), + 'sequence_number' => $serial->nextSequenceNumber, + 'customer_sequence_number' => $serial->nextCustomerSequenceNumber, + 'status' => Invoice::STATUS_DRAFT, + 'paid_status' => Invoice::STATUS_UNPAID, + 'due_amount' => $invoice->total, + '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, + ...$carriedOver, + ]); + + $newInvoice->unique_hash = Hashids::connection(HashidConnection::Invoice->value)->encode($newInvoice->id); + $newInvoice->save(); + + $invoice->load('items.taxes'); + $this->documentItemService->createItems($newInvoice, $invoice->items->toArray()); + + if ($invoice->taxes) { + $this->documentItemService->createTaxes($newInvoice, $invoice->taxes->toArray()); + } + + if ($invoice->fields()->exists()) { + $customFields = $invoice->fields->map(fn ($answer) => [ + 'id' => $answer->custom_field_id, + 'value' => $answer->defaultAnswer, + ])->all(); + + $this->customFieldValueWriter->attach($newInvoice, $customFields); + } + + return $newInvoice; + } + + public function convertToEstimate(Invoice $invoice): Estimate + { + $invoice->load(['items', 'items.taxes', 'customer', 'taxes']); + + $serial = (new SerialNumberService) + ->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([ + '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(), + 'template_name' => $invoice->getEstimateTemplateName(), + 'status' => Estimate::STATUS_DRAFT, + '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, + ...$carriedOver, + ]); + + $estimate->unique_hash = Hashids::connection(HashidConnection::Estimate->value)->encode($estimate->id); + $estimate->save(); + + $this->documentItemService->createItems($estimate, $invoice->items->toArray()); + + if ($invoice->taxes) { + $this->documentItemService->createTaxes($estimate, $invoice->taxes->toArray()); + } + + if ($invoice->fields()->exists()) { + $customFields = $invoice->fields->map(fn ($answer) => [ + 'id' => $answer->custom_field_id, + 'value' => $answer->defaultAnswer, + ])->all(); + + $this->customFieldValueWriter->attach($estimate, $customFields); + } + + return $estimate; + } + + public function changeStatus(Invoice $invoice, string $status): void + { + if ($status == Invoice::STATUS_SENT) { + $invoice->status = Invoice::STATUS_SENT; + $invoice->sent = true; + $invoice->save(); + } elseif ($status == Invoice::STATUS_COMPLETED) { + $paid = (int) $invoice->allocations()->sum('amount'); + $credited = $this->creditNoteService->creditedTotal($invoice); + $outstanding = max(0, (int) $invoice->total - $paid - $credited); + + if ( + $outstanding !== 0 + || (int) $invoice->due_amount !== 0 + || (int) $invoice->base_due_amount !== 0 + ) { + throw ValidationException::withMessages([ + 'status' => ['invoice_must_be_settled_before_completion'], + ]); + } + + $invoice->changeInvoiceStatus((int) $invoice->due_amount); + } + } +} diff --git a/app/Domains/Sales/Application/RecurringInvoiceService.php b/app/Domains/Sales/Application/RecurringInvoiceService.php new file mode 100644 index 00000000..e19b86dd --- /dev/null +++ b/app/Domains/Sales/Application/RecurringInvoiceService.php @@ -0,0 +1,271 @@ + $attributes + * @param array> $items + * @param array>|null $taxes + */ + public function create( + array $attributes, + array $items, + ?array $taxes = null, + ?iterable $customFields = null, + ): RecurringInvoice { + $recurringInvoice = RecurringInvoice::create($attributes); + + $companyCurrency = CompanySetting::getSetting('currency', $recurringInvoice->company_id); + + if ((string) $recurringInvoice['currency_id'] !== $companyCurrency) { + $this->exchangeRateRecorder->record($recurringInvoice); + } + + $this->createItems($recurringInvoice, $items); + + if ($taxes) { + $this->createTaxes($recurringInvoice, $taxes); + } + + if ($customFields) { + $this->customFieldValueWriter->attach($recurringInvoice, $customFields); + } + + return $recurringInvoice; + } + + public function update( + RecurringInvoice $recurringInvoice, + array $attributes, + array $items, + ?array $taxes = null, + ?iterable $customFields = null, + ): RecurringInvoice { + $recurringInvoice->update($attributes); + + $companyCurrency = CompanySetting::getSetting('currency', $recurringInvoice->company_id); + + if ((string) $attributes['currency_id'] !== $companyCurrency) { + $this->exchangeRateRecorder->record($recurringInvoice); + } + + $recurringInvoice->items()->delete(); + $this->createItems($recurringInvoice, $items); + + $recurringInvoice->taxes()->delete(); + if ($taxes) { + $this->createTaxes($recurringInvoice, $taxes); + } + + if ($customFields) { + $this->customFieldValueWriter->update($recurringInvoice, $customFields); + } + + return $recurringInvoice; + } + + public function delete(Collection $ids): bool + { + foreach ($ids as $id) { + $recurringInvoice = RecurringInvoice::find($id); + + // 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]); + } + + $lineItems = $recurringInvoice->items(); + + if ($lineItems->exists()) { + $lineItems->delete(); + } + + if ($recurringInvoice->taxes()->exists()) { + $recurringInvoice->taxes()->delete(); + } + + $recurringInvoice->delete(); + } + + return true; + } + + public function generateInvoice(RecurringInvoice $recurringInvoice): void + { + if (Carbon::now()->lessThan($recurringInvoice->starts_at)) { + return; + } + + if ($recurringInvoice->limit_by == 'DATE') { + $startDate = Carbon::today()->format('Y-m-d'); + $endDate = $recurringInvoice->limit_date; + + if ($endDate >= $startDate) { + $this->createInvoiceFromRecurring($recurringInvoice); + $recurringInvoice->updateNextInvoiceDate(); + } else { + $recurringInvoice->markStatusAsCompleted(); + } + } elseif ($recurringInvoice->limit_by == 'COUNT') { + $invoiceCount = Invoice::where('recurring_invoice_id', $recurringInvoice->id)->count(); + + if ($invoiceCount < $recurringInvoice->limit_count) { + $this->createInvoiceFromRecurring($recurringInvoice); + $recurringInvoice->updateNextInvoiceDate(); + } else { + $recurringInvoice->markStatusAsCompleted(); + } + } else { + $this->createInvoiceFromRecurring($recurringInvoice); + $recurringInvoice->updateNextInvoiceDate(); + } + } + + private function createInvoiceFromRecurring(RecurringInvoice $recurringInvoice): void + { + $serial = (new SerialNumberService) + ->setModel(new Invoice) + ->setCompany($recurringInvoice->company_id) + ->setCustomer($recurringInvoice->customer_id) + ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) + ->setNextNumbers(); + + $days = intval(CompanySetting::getSetting('invoice_due_date_days', $recurringInvoice->company_id)); + + if (! $days || $days == 'null') { + $days = 7; + } + + $newInvoice['creator_id'] = $recurringInvoice->creator_id; + $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; + $newInvoice['sub_total'] = $recurringInvoice->sub_total; + $newInvoice['tax_per_item'] = $recurringInvoice->tax_per_item; + $newInvoice['tax_included'] = $recurringInvoice->tax_included; + $newInvoice['discount_per_item'] = $recurringInvoice->discount_per_item; + $newInvoice['tax'] = $recurringInvoice->tax; + $newInvoice['total'] = $recurringInvoice->total; + $newInvoice['customer_id'] = $recurringInvoice->customer_id; + $newInvoice['currency_id'] = Customer::find($recurringInvoice->customer_id)->currency_id; + $newInvoice['template_name'] = $recurringInvoice->template_name; + $newInvoice['due_amount'] = $recurringInvoice->total; + $newInvoice['recurring_invoice_id'] = $recurringInvoice->id; + $newInvoice['discount_val'] = $recurringInvoice->discount_val; + $newInvoice['discount'] = $recurringInvoice->discount; + $newInvoice['discount_type'] = $recurringInvoice->discount_type; + $newInvoice['notes'] = $recurringInvoice->notes; + $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['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(); + + $recurringInvoice->load('items.taxes'); + $this->documentItemService->createItems($invoice, $recurringInvoice->items->toArray()); + + if ($recurringInvoice->taxes()->exists()) { + $this->documentItemService->createTaxes($invoice, $recurringInvoice->taxes->toArray()); + } + + if ($recurringInvoice->fields()->exists()) { + $customField = []; + + 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' => $customer->toArray(), + 'company' => Company::find($invoice->company_id), + ]; + + $this->invoiceService->send($invoice, $data); + } + } + + private function createItems(RecurringInvoice $recurringInvoice, array $items): void + { + foreach ($items as $item) { + $item['company_id'] = $recurringInvoice->company_id; + $createdItem = $recurringInvoice->items()->create($item); + if (array_key_exists('taxes', $item) && $item['taxes']) { + foreach ($item['taxes'] as $tax) { + if (empty($tax['tax_type_id'])) { + continue; + } + + $tax['company_id'] = $recurringInvoice->company_id; + if (gettype($tax['amount']) !== 'NULL') { + $createdItem->taxes()->create($tax); + } + } + } + } + } + + /** + * 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) { + 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 new file mode 100644 index 00000000..651d1a10 --- /dev/null +++ b/app/Domains/Sales/Http/Controllers/Company/InvoicesController.php @@ -0,0 +1,275 @@ +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::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::query()->whereCompany()->count(), + ], + ]); + } + + /** + * Persist a new invoice, optionally mail it straight away, and queue its + * PDF render. + * + * @param Request $request + * @return JsonResponse + */ + public function store(InvoicesRequest $request) + { + $this->authorize('create', Invoice::class); + + $invoice = $this->invoiceService->create( + attributes: $request->getInvoicePayload(), + items: $request->input('items'), + taxes: $request->has('taxes') ? $request->input('taxes') : null, + customFields: $this->customFields($request), + ); + + if ($request->exists('invoiceSend')) { + $this->invoiceService->send($invoice, $request->only(['subject', 'body'])); + } + + dispatch(new GenerateInvoicePdfJob($invoice)); + + return InvoiceResource::make($invoice); + } + + /** + * One invoice, loaded with what its detail page reads. + * + * @return JsonResponse + */ + public function show(Request $request, Invoice $invoice) + { + $this->authorize('view', $invoice); + + if ($invoice->isCreditNote()) { + return new CreditNoteResource($invoice->load('relatedInvoice')); + } + + // Feeds the credit-note banner on the detail page: how much of the + // invoice has been credited, and how much of each line, so the partial + // credit form can offer the remaining quantities. + return new InvoiceResource($invoice->load([ + 'creditNotes:id,related_invoice_id,invoice_number,total', + 'creditNotes.items:id,invoice_id,source_invoice_item_id,quantity', + 'allocations.payment', + ])); + } + + /** + * Overwrite an invoice, lines and taxes included, and re-render its PDF. + * + * @param Request $request + * @return JsonResponse + */ + public function update(InvoicesRequest $request, Invoice $invoice) + { + $this->authorize('update', $invoice); + + $invoice = $this->invoiceService->update( + invoice: $invoice, + attributes: $request->getInvoicePayload(), + items: $request->input('items'), + taxes: $request->has('taxes') ? $request->input('taxes') : null, + customFields: $this->customFields($request), + ); + + dispatch(new GenerateInvoicePdfJob($invoice, true)); + + return InvoiceResource::make($invoice); + } + + /** + * Bulk removal. Ids outside the active company are silently skipped. + * + * @param Request $request + * @return JsonResponse + */ + public function delete(DeleteInvoiceRequest $request) + { + $this->authorize('delete multiple invoices'); + + $ids = Invoice::whereCompany() + ->whereIn('id', $request->ids) + ->pluck('id'); + + $this->invoiceService->delete($ids); + + return response()->json(['success' => true]); + } + + public function send(SendInvoiceRequest $request, Invoice $invoice) + { + $this->authorize('send invoice', $invoice); + + $this->invoiceService->send($invoice, $request->all()); + + return response()->json(['success' => true]); + } + + public function sendPreview(SendInvoiceRequest $request, Invoice $invoice) + { + $this->authorize('send invoice', $invoice); + + $markdown = new Markdown(app('view'), config('mail.markdown')); + + $data = $this->invoiceService->sendInvoiceData($invoice, $request->all()); + $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. + $view = $invoice->isCreditNote() ? 'emails.send.credit-note' : 'emails.send.invoice'; + + return $markdown->render($view, ['data' => $data]); + } + + public function clone(Request $request, Invoice $invoice) + { + $this->authorize('view', $invoice); + $this->authorize('create', Invoice::class); + + // Cloning a credit note would mint a positive invoice out of a reversal + // document. Domain rule violation (422), not an authorization failure. + if ($invoice->isCreditNote()) { + throw ValidationException::withMessages([ + 'invoice' => ['a_credit_note_cannot_be_cloned'], + ]); + } + + $newInvoice = $this->invoiceService->clone($invoice); + + return new InvoiceResource($newInvoice); + } + + public function convertToEstimate(Request $request, Invoice $invoice) + { + // Authorize access to the source invoice (tenant isolation) in addition + // to the ability to create an estimate. + $this->authorize('view', $invoice); + $this->authorize('create', Estimate::class); + + // Same reason as clone(): the conversion copies the amounts unnegated, + // so a credit note would become a positive estimate. + if ($invoice->isCreditNote()) { + throw ValidationException::withMessages([ + 'invoice' => ['a_credit_note_cannot_be_converted_to_an_estimate'], + ]); + } + + $estimate = $this->invoiceService->convertToEstimate($invoice); + + return new EstimateResource($estimate); + } + + public function createCreditNote(CreateCreditNoteRequest $request, Invoice $invoice) + { + $this->authorize('create credit note', $invoice); + + // A credit note can only reverse a real invoice, never another credit + // note. This is a domain rule (422), not an authorization failure (403). + if ($invoice->isCreditNote()) { + throw ValidationException::withMessages([ + 'invoice' => ['a_credit_note_cannot_be_created_from_a_credit_note'], + ]); + } + + // A draft was never issued, so there is nothing to reverse: edit or + // delete it instead. + if ($invoice->status === Invoice::STATUS_DRAFT) { + throw ValidationException::withMessages([ + 'invoice' => ['a_draft_invoice_cannot_be_credited'], + ]); + } + + // How much of the invoice is still creditable, and whether the credit + // fits inside its unpaid balance, is decided by the service under a row + // lock. Guarding it here would race. + $creditNote = $this->creditNoteService->create( + $invoice, + $request->input('items', []), + $request->input('reason') + ); + + GenerateInvoicePdfJob::dispatch($creditNote); + + // The original's own PDF changed too: its balance moved and it now + // carries the cancellation banner, so the stored file is replaced. + GenerateInvoicePdfJob::dispatch($invoice->fresh(), true); + + return (new CreditNoteResource($creditNote)) + ->response() + ->setStatusCode(201); + } + + public function changeStatus(ChangeInvoiceStatusRequest $request, Invoice $invoice) + { + $this->authorize('send invoice', $invoice); + + $this->invoiceService->changeStatus($invoice, $request->status); + + return response()->json(['success' => true]); + } + + private function customFields(InvoicesRequest $request): ?iterable + { + $customFields = $request->input('customFields'); + + return is_iterable($customFields) ? $customFields : null; + } +}