From 52693444584a38248ee86fe4e095d4dda6a2e6ba Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Fri, 21 Aug 2026 01:26:48 +0200 Subject: [PATCH] feat(sales): fresh sales implementation --- .../Sales/Application/SerialNumberService.php | 383 ++++++++ .../Sales/Console/CheckEstimateStatus.php | 48 + .../Sales/Console/CheckInvoiceStatus.php | 45 + .../Company/EstimateTemplatesController.php | 24 + .../Company/EstimatesController.php | 179 ++++ .../Company/InvoiceTemplatesController.php | 27 + .../Company/RecurringInvoiceController.php | 132 +++ .../RecurringInvoiceFrequencyController.php | 36 + .../Company/SerialNumberController.php | 92 ++ .../AcceptEstimateController.php | 39 + .../CustomerPortal/EstimatePdfController.php | 92 ++ .../CustomerPortal/EstimatesController.php | 78 ++ .../CustomerPortal/InvoicePdfController.php | 104 +++ .../CustomerPortal/InvoicesController.php | 75 ++ .../Http/Requests/DeleteEstimatesRequest.php | 33 + .../Http/Requests/DeleteInvoiceRequest.php | 46 + .../Sales/Http/Requests/EstimatesRequest.php | 140 +++ .../Sales/Http/Requests/InvoicesRequest.php | 147 +++ .../Http/Requests/RecurringInvoiceRequest.php | 182 ++++ .../Http/Requests/SendEstimatesRequest.php | 35 + .../Http/Requests/SendInvoiceRequest.php | 35 + .../CustomerPortal/EstimateCollection.php | 23 + .../CustomerPortal/EstimateItemCollection.php | 23 + .../CustomerPortal/EstimateItemResource.php | 56 ++ .../CustomerPortal/EstimateResource.php | 88 ++ .../CustomerPortal/InvoiceCollection.php | 24 + .../CustomerPortal/InvoiceItemCollection.php | 23 + .../CustomerPortal/InvoiceItemResource.php | 57 ++ .../CustomerPortal/InvoiceResource.php | 101 +++ .../Http/Resources/EstimateCollection.php | 23 + .../Http/Resources/EstimateItemCollection.php | 23 + .../Http/Resources/EstimateItemResource.php | 58 ++ .../Sales/Http/Resources/EstimateResource.php | 99 ++ .../Http/Resources/InvoiceCollection.php | 25 + .../Http/Resources/InvoiceItemCollection.php | 23 + .../Http/Resources/InvoiceItemResource.php | 58 ++ .../Sales/Http/Resources/InvoiceResource.php | 268 ++++++ .../Resources/RecurringInvoiceCollection.php | 23 + .../Resources/RecurringInvoiceResource.php | 103 +++ .../Sales/Jobs/GenerateEstimatePdfJob.php | 54 ++ .../Sales/Jobs/GenerateInvoicePdfJob.php | 54 ++ app/Domains/Sales/Mail/EstimateViewedMail.php | 48 + app/Domains/Sales/Mail/InvoiceViewedMail.php | 48 + app/Domains/Sales/Mail/SendEstimateMail.php | 104 +++ app/Domains/Sales/Mail/SendInvoiceMail.php | 104 +++ app/Domains/Sales/Models/Estimate.php | 526 +++++++++++ app/Domains/Sales/Models/EstimateItem.php | 88 ++ app/Domains/Sales/Models/Invoice.php | 847 ++++++++++++++++++ app/Domains/Sales/Models/InvoiceItem.php | 159 ++++ app/Domains/Sales/Models/RecurringInvoice.php | 336 +++++++ app/Domains/Sales/Policies/EstimatePolicy.php | 98 ++ app/Domains/Sales/Policies/InvoicePolicy.php | 109 +++ .../Sales/Policies/RecurringInvoicePolicy.php | 91 ++ 53 files changed, 5736 insertions(+) create mode 100644 app/Domains/Sales/Application/SerialNumberService.php create mode 100644 app/Domains/Sales/Console/CheckEstimateStatus.php create mode 100644 app/Domains/Sales/Console/CheckInvoiceStatus.php create mode 100644 app/Domains/Sales/Http/Controllers/Company/EstimateTemplatesController.php create mode 100644 app/Domains/Sales/Http/Controllers/Company/EstimatesController.php create mode 100644 app/Domains/Sales/Http/Controllers/Company/InvoiceTemplatesController.php create mode 100644 app/Domains/Sales/Http/Controllers/Company/RecurringInvoiceController.php create mode 100644 app/Domains/Sales/Http/Controllers/Company/RecurringInvoiceFrequencyController.php create mode 100644 app/Domains/Sales/Http/Controllers/Company/SerialNumberController.php create mode 100644 app/Domains/Sales/Http/Controllers/CustomerPortal/AcceptEstimateController.php create mode 100644 app/Domains/Sales/Http/Controllers/CustomerPortal/EstimatePdfController.php create mode 100644 app/Domains/Sales/Http/Controllers/CustomerPortal/EstimatesController.php create mode 100644 app/Domains/Sales/Http/Controllers/CustomerPortal/InvoicePdfController.php create mode 100644 app/Domains/Sales/Http/Controllers/CustomerPortal/InvoicesController.php create mode 100644 app/Domains/Sales/Http/Requests/DeleteEstimatesRequest.php create mode 100644 app/Domains/Sales/Http/Requests/DeleteInvoiceRequest.php create mode 100644 app/Domains/Sales/Http/Requests/EstimatesRequest.php create mode 100644 app/Domains/Sales/Http/Requests/InvoicesRequest.php create mode 100644 app/Domains/Sales/Http/Requests/RecurringInvoiceRequest.php create mode 100644 app/Domains/Sales/Http/Requests/SendEstimatesRequest.php create mode 100644 app/Domains/Sales/Http/Requests/SendInvoiceRequest.php create mode 100644 app/Domains/Sales/Http/Resources/CustomerPortal/EstimateCollection.php create mode 100644 app/Domains/Sales/Http/Resources/CustomerPortal/EstimateItemCollection.php create mode 100644 app/Domains/Sales/Http/Resources/CustomerPortal/EstimateItemResource.php create mode 100644 app/Domains/Sales/Http/Resources/CustomerPortal/EstimateResource.php create mode 100644 app/Domains/Sales/Http/Resources/CustomerPortal/InvoiceCollection.php create mode 100644 app/Domains/Sales/Http/Resources/CustomerPortal/InvoiceItemCollection.php create mode 100644 app/Domains/Sales/Http/Resources/CustomerPortal/InvoiceItemResource.php create mode 100644 app/Domains/Sales/Http/Resources/CustomerPortal/InvoiceResource.php create mode 100644 app/Domains/Sales/Http/Resources/EstimateCollection.php create mode 100644 app/Domains/Sales/Http/Resources/EstimateItemCollection.php create mode 100644 app/Domains/Sales/Http/Resources/EstimateItemResource.php create mode 100644 app/Domains/Sales/Http/Resources/EstimateResource.php create mode 100644 app/Domains/Sales/Http/Resources/InvoiceCollection.php create mode 100644 app/Domains/Sales/Http/Resources/InvoiceItemCollection.php create mode 100644 app/Domains/Sales/Http/Resources/InvoiceItemResource.php create mode 100644 app/Domains/Sales/Http/Resources/InvoiceResource.php create mode 100644 app/Domains/Sales/Http/Resources/RecurringInvoiceCollection.php create mode 100644 app/Domains/Sales/Http/Resources/RecurringInvoiceResource.php create mode 100644 app/Domains/Sales/Jobs/GenerateEstimatePdfJob.php create mode 100644 app/Domains/Sales/Jobs/GenerateInvoicePdfJob.php create mode 100644 app/Domains/Sales/Mail/EstimateViewedMail.php create mode 100644 app/Domains/Sales/Mail/InvoiceViewedMail.php create mode 100644 app/Domains/Sales/Mail/SendEstimateMail.php create mode 100644 app/Domains/Sales/Mail/SendInvoiceMail.php create mode 100644 app/Domains/Sales/Models/Estimate.php create mode 100644 app/Domains/Sales/Models/EstimateItem.php create mode 100644 app/Domains/Sales/Models/Invoice.php create mode 100644 app/Domains/Sales/Models/InvoiceItem.php create mode 100644 app/Domains/Sales/Models/RecurringInvoice.php create mode 100644 app/Domains/Sales/Policies/EstimatePolicy.php create mode 100644 app/Domains/Sales/Policies/InvoicePolicy.php create mode 100644 app/Domains/Sales/Policies/RecurringInvoicePolicy.php diff --git a/app/Domains/Sales/Application/SerialNumberService.php b/app/Domains/Sales/Application/SerialNumberService.php new file mode 100644 index 00000000..4295c251 --- /dev/null +++ b/app/Domains/Sales/Application/SerialNumberService.php @@ -0,0 +1,383 @@ +model = $model; + + return $this; + } + + /** + * Adopt an existing row's sequences so an update keeps its numbers. + * + * The per-customer sequence is only adopted while the row still belongs to + * the customer in play; moving a document to another customer therefore + * leaves it to be renumbered for the new one. + */ + public function setModelObject($id = null) + { + $this->ob = $this->model::find($id); + + if ($this->ob && $this->ob->sequence_number) { + $this->nextSequenceNumber = $this->ob->sequence_number; + } + + if (isset($this->ob->customer_sequence_number, $this->customer) + && $this->ob->customer_id == $this->customer->id) { + $this->nextCustomerSequenceNumber = $this->ob->customer_sequence_number; + } + + return $this; + } + + /** + * @return $this + */ + public function setCompany($company) + { + $this->company = $company; + + return $this; + } + + /** + * Resolve the customer the per-customer sequence and series belong to. + * + * @return $this + */ + public function setCustomer($customer = null) + { + $this->customer = Customer::find($customer); + + return $this; + } + + /** + * Override the company setting the number format is read from. + * + * Without this the key is derived from the model class name, which is not + * enough for documents that share a table (credit notes are Invoice rows + * but carry their own format). + * + * @return $this + */ + public function setSettingKey(string $key) + { + $this->settingKey = $key; + + return $this; + } + + /** + * Restrict the sequence lookups to a subset of the model's rows. + * + * Takes column => value constraints that are applied on top of the company + * (and customer) filters, so documents sharing a table can each keep an + * independent, gapless sequence. + * + * @return $this + */ + public function setSequenceScope(array $constraints) + { + $this->sequenceScope = $constraints; + + return $this; + } + + /** + * Render the number the next document should carry. + * + * Passing no format falls back to the company setting for this document + * kind. + * + * @return string + */ + public function getNextNumber(?string $format = null) + { + $derivedKey = strtolower(class_basename($this->model)).'_number_format'; + + if ($format === null) { + $format = CompanySetting::getSetting( + $this->settingKey ?: $derivedKey, + $this->company + ); + } + + $this->setNextNumbers(); + + return $this->generateSerialNumber($format); + } + + /** + * Fill in whichever of the two sequences is still unresolved. + */ + public function setNextNumbers() + { + if (! $this->nextSequenceNumber) { + $this->setNextSequenceNumber(); + } + + if (! $this->nextCustomerSequenceNumber) { + $this->setNextCustomerSequenceNumber(); + } + + return $this; + } + + /** + * Resolve the company-wide sequence as one past the highest in use. + * + * @return $this + */ + public function setNextSequenceNumber() + { + $highest = $this->scopedQuery() + ->whereNotNull('sequence_number') + ->orderByDesc('sequence_number') + ->first(); + + $this->nextSequenceNumber = $highest ? $highest->sequence_number + 1 : 1; + + return $this; + } + + /** + * Resolve the per-customer sequence as one past the highest in use. + * + * With no customer resolved the lookup falls back to customer 1 rather + * than skipping the customer filter. + * + * @return self + */ + public function setNextCustomerSequenceNumber() + { + $highest = $this->scopedQuery() + ->where('customer_id', $this->customer ? $this->customer->id : 1) + ->whereNotNull('customer_sequence_number') + ->orderByDesc('customer_sequence_number') + ->first(); + + $this->nextCustomerSequenceNumber = $highest ? $highest->customer_sequence_number + 1 : 1; + + return $this; + } + + /** + * List the recognised tokens of a format, in the order they appear. + * + * Each entry is a `name` / `value` pair; a token written without a value + * yields an empty string. Tokens whose name is not one this service knows + * about are dropped, as is any text between tokens. + */ + public static function getPlaceholders(string $format) + { + $recognised = collect(); + $end = strlen($format); + $cursor = 0; + + while ($cursor < $end) { + $token = self::readToken($format, $cursor, $end); + + if ($token === null) { + $cursor++; + + continue; + } + + [$name, $value, $cursor] = $token; + + if (in_array($name, self::VALID_PLACEHOLDERS)) { + $recognised->push([ + 'name' => $name, + 'value' => $value, + ]); + } + } + + return $recognised; + } + + /** + * Read the token opening at the given offset, if there is one. + * + * Both the name/value split and the value itself are ambiguous: a value + * may be written with or without a leading colon, and may be either a run + * of word bytes or a single arbitrary byte. Candidates are therefore tried + * longest-name first, colon first, and word-run before single byte; the + * first spelling whose closing braces line up wins. + * + * @return array{0: string, 1: string, 2: int}|null name, value, offset just past the token + */ + private static function readToken(string $format, int $start, int $end) + { + if (substr($format, $start, 2) !== '{{') { + return null; + } + + $nameAt = $start + 2; + + for ($width = self::runLength($format, self::NAME_BYTES, $nameAt, $end); $width > 0; $width--) { + foreach ([true, false] as $colon) { + $valueAt = $nameAt + $width; + + if ($colon) { + if (($format[$valueAt] ?? null) !== ':') { + continue; + } + + $valueAt++; + } + + $value = self::readValue($format, $valueAt, $end); + + if ($value !== null) { + return [substr($format, $nameAt, $width), $value[0], $value[1]]; + } + } + } + + return null; + } + + /** + * Read a token's value plus its closing braces at the given offset. + * + * @return array{0: string, 1: int}|null value, offset just past the closing braces + */ + private static function readValue(string $format, int $at, int $end) + { + $run = self::runLength($format, self::VALUE_BYTES, $at, $end); + + if ($run > 0 && $run <= self::VALUE_LIMIT && substr($format, $at + $run, 2) === '}}') { + return [substr($format, $at, $run), $at + $run + 2]; + } + + $byte = $format[$at] ?? null; + + if ($byte !== null && $byte !== "\n" && substr($format, $at + 1, 2) === '}}') { + return [$byte, $at + 3]; + } + + if (substr($format, $at, 2) === '}}') { + return ['', $at + 2]; + } + + return null; + } + + /** + * Count the bytes at the given offset that belong to the given set. + */ + private static function runLength(string $format, string $bytes, int $at, int $end): int + { + return $at < $end ? strspn($format, $bytes, $at) : 0; + } + + /** + * Concatenate what every recognised token of the format renders to. + * + * @return string + */ + private function generateSerialNumber(string $format) + { + $serialNumber = ''; + + foreach (self::getPlaceholders($format) as $placeholder) { + $serialNumber .= $this->renderPlaceholder($placeholder['name'], $placeholder['value']); + } + + return $serialNumber; + } + + /** + * Render one token. + * + * A token whose name is not one of the computed ones (a series or a + * delimiter) simply renders its own value. + */ + private function renderPlaceholder(string $name, string $value): string + { + return match ($name) { + 'SEQUENCE' => str_pad($this->nextSequenceNumber, $value ?: 6, 0, STR_PAD_LEFT), + 'CUSTOMER_SEQUENCE' => str_pad($this->nextCustomerSequenceNumber, $value, 0, STR_PAD_LEFT), + 'DATE_FORMAT' => date($value ?: 'Y'), + 'RANDOM_SEQUENCE' => substr(bin2hex(random_bytes($value ?: 6)), 0, $value ?: 6), + 'CUSTOMER_SERIES' => isset($this->customer) ? ($this->customer->prefix ?? 'CST') : 'CST', + default => $value, + }; + } + + /** + * Start a lookup narrowed to the company and the configured scope. + */ + private function scopedQuery() + { + $query = $this->model::query()->where('company_id', $this->company); + + foreach ($this->sequenceScope as $column => $value) { + $query->where($column, $value); + } + + return $query; + } +} diff --git a/app/Domains/Sales/Console/CheckEstimateStatus.php b/app/Domains/Sales/Console/CheckEstimateStatus.php new file mode 100644 index 00000000..b20b7080 --- /dev/null +++ b/app/Domains/Sales/Console/CheckEstimateStatus.php @@ -0,0 +1,48 @@ +whereDate('expiry_date', '<', $today) + ->get(); + + foreach ($lapsed as $estimate) { + $estimate->status = $expired; + printf("Estimate %s is EXPIRED \n", $estimate->estimate_number); + $estimate->save(); + } + } +} diff --git a/app/Domains/Sales/Console/CheckInvoiceStatus.php b/app/Domains/Sales/Console/CheckInvoiceStatus.php new file mode 100644 index 00000000..ea8e7374 --- /dev/null +++ b/app/Domains/Sales/Console/CheckInvoiceStatus.php @@ -0,0 +1,45 @@ +whereNotIn('status', $exempt) + ->where('overdue', false) + ->whereDate('due_date', '<', $today) + ->get(); + + foreach ($overdue as $invoice) { + $invoice->overdue = true; + printf("Invoice %s is OVERDUE \n", $invoice->invoice_number); + $invoice->save(); + } + } +} diff --git a/app/Domains/Sales/Http/Controllers/Company/EstimateTemplatesController.php b/app/Domains/Sales/Http/Controllers/Company/EstimateTemplatesController.php new file mode 100644 index 00000000..51234116 --- /dev/null +++ b/app/Domains/Sales/Http/Controllers/Company/EstimateTemplatesController.php @@ -0,0 +1,24 @@ +authorize('viewAny', Estimate::class); + + return response()->json([ + 'estimateTemplates' => PdfTemplateUtils::getFormattedTemplates('estimate'), + ]); + } +} diff --git a/app/Domains/Sales/Http/Controllers/Company/EstimatesController.php b/app/Domains/Sales/Http/Controllers/Company/EstimatesController.php new file mode 100644 index 00000000..54874ef5 --- /dev/null +++ b/app/Domains/Sales/Http/Controllers/Company/EstimatesController.php @@ -0,0 +1,179 @@ +authorize('viewAny', Estimate::class); + + $filters = $request->all(); + $perPage = $request->has('limit') ? $request->input('limit') : 10; + + $page = Estimate::query() + ->whereCompany() + ->join('customers', fn ($join) => $join->on('customers.id', '=', 'estimates.customer_id')) + ->applyFilters($filters) + ->select(['estimates.*', 'customers.name']) + ->orderByDesc('created_at') + ->paginateData($perPage); + + return EstimateResource::collection($page)->additional([ + 'meta' => [ + 'estimate_total_count' => Estimate::query()->whereCompany()->count(), + ], + ]); + } + + /** + * Persist a new estimate, optionally mailing it straight away, and queue the + * PDF render. + */ + public function store(EstimatesRequest $request) + { + $this->authorize('create', Estimate::class); + + $estimate = $this->estimateService->create(...$this->writeArguments($request)); + + if ($request->has('estimateSend')) { + $this->estimateService->send($estimate, $request->only(['title', 'body'])); + } + + GenerateEstimatePdfJob::dispatch($estimate); + + return EstimateResource::make($estimate); + } + + public function show(Request $request, Estimate $estimate) + { + $this->authorize('view', $estimate); + + return EstimateResource::make($estimate); + } + + /** + * Overwrite an estimate — lines and taxes are replaced wholesale — and + * re-render its PDF. + */ + public function update(EstimatesRequest $request, Estimate $estimate) + { + $this->authorize('update', $estimate); + + $estimate = $this->estimateService->update($estimate, ...$this->writeArguments($request)); + + GenerateEstimatePdfJob::dispatch($estimate, true); + + return EstimateResource::make($estimate); + } + + /** + * Bulk removal. Ids outside the active company are silently skipped. + */ + public function delete(DeleteEstimatesRequest $request) + { + $this->authorize('delete multiple estimates'); + + $ids = Estimate::query() + ->whereCompany() + ->whereIn('id', $request->input('ids')) + ->pluck('id'); + + Estimate::destroy($ids); + + return response()->json(['success' => true]); + } + + public function send(SendEstimatesRequest $request, Estimate $estimate) + { + $this->authorize('send estimate', $estimate); + + return response()->json( + $this->estimateService->send($estimate, $request->all()) + ); + } + + /** + * Render the mail body the customer would receive, without sending it. + */ + public function sendPreview(SendEstimatesRequest $request, Estimate $estimate) + { + $this->authorize('send estimate', $estimate); + + $data = $this->estimateService->sendEstimateData($estimate, $request->all()); + $data['url'] = $estimate->estimatePdfUrl; + + $renderer = new Markdown(view(), config('mail.markdown')); + + return $renderer->render('emails.send.estimate', ['data' => $data]); + } + + public function clone(Request $request, Estimate $estimate) + { + $this->authorize('view', $estimate); + $this->authorize('create', Estimate::class); + + return EstimateResource::make($this->estimateService->clone($estimate)); + } + + /** + * Reading the source estimate is checked on top of the invoice-create + * ability so the conversion cannot reach across companies. + */ + public function convertToInvoice(Request $request, Estimate $estimate) + { + $this->authorize('view', $estimate); + $this->authorize('create', Invoice::class); + + return InvoiceResource::make($this->estimateService->convertToInvoice($estimate)); + } + + public function changeStatus(Request $request, Estimate $estimate) + { + $this->authorize('send estimate', $estimate); + + $this->estimateService->changeStatus($estimate, $request->input('status')); + + return response()->json(['success' => true]); + } + + /** + * The arguments create() and update() share, keyed by parameter name. + * + * @return array + */ + private function writeArguments(EstimatesRequest $request): array + { + $fields = $request->input('customFields'); + + return [ + 'attributes' => $request->getEstimatePayload(), + 'items' => $request->input('items'), + 'taxes' => $request->has('taxes') ? $request->input('taxes') : null, + 'customFields' => is_iterable($fields) ? $fields : null, + ]; + } +} diff --git a/app/Domains/Sales/Http/Controllers/Company/InvoiceTemplatesController.php b/app/Domains/Sales/Http/Controllers/Company/InvoiceTemplatesController.php new file mode 100644 index 00000000..7d0d90a2 --- /dev/null +++ b/app/Domains/Sales/Http/Controllers/Company/InvoiceTemplatesController.php @@ -0,0 +1,27 @@ +authorize('viewAny', Invoice::class); + + return response()->json([ + 'invoiceTemplates' => PdfTemplateUtils::getFormattedTemplates('invoice'), + ]); + } +} diff --git a/app/Domains/Sales/Http/Controllers/Company/RecurringInvoiceController.php b/app/Domains/Sales/Http/Controllers/Company/RecurringInvoiceController.php new file mode 100644 index 00000000..328b562e --- /dev/null +++ b/app/Domains/Sales/Http/Controllers/Company/RecurringInvoiceController.php @@ -0,0 +1,132 @@ +authorize('viewAny', RecurringInvoice::class); + + $perPage = $request->has('limit') ? $request->input('limit') : 10; + + $schedules = RecurringInvoice::whereCompany() + ->applyFilters($request->all()) + ->paginateData($perPage); + + $companyTotal = RecurringInvoice::whereCompany()->count(); + + return RecurringInvoiceResource::collection($schedules) + ->additional(['meta' => [ + 'recurring_invoice_total_count' => $companyTotal, + ]]); + } + + /** + * Set up a new schedule from the submitted template. + */ + public function store(RecurringInvoiceRequest $request) + { + $this->authorize('create', RecurringInvoice::class); + + $schedule = $this->recurringInvoiceService->create( + attributes: $request->getRecurringInvoicePayload(), + items: $request->input('items'), + taxes: $request->has('taxes') ? $request->input('taxes') : null, + customFields: $this->customFields($request), + ); + + return new RecurringInvoiceResource($schedule); + } + + /** + * Show one schedule. + */ + public function show(RecurringInvoice $recurringInvoice) + { + $this->authorize('view', $recurringInvoice); + + return new RecurringInvoiceResource($recurringInvoice); + } + + /** + * Restate a schedule, template and all. + * + * Items and taxes are replaced wholesale rather than reconciled, so the + * submission is the schedule's new contents in full. + */ + public function update(RecurringInvoiceRequest $request, RecurringInvoice $recurringInvoice) + { + $this->authorize('update', $recurringInvoice); + + $this->recurringInvoiceService->update( + recurringInvoice: $recurringInvoice, + attributes: $request->getRecurringInvoicePayload(), + items: $request->input('items'), + taxes: $request->has('taxes') ? $request->input('taxes') : null, + customFields: $this->customFields($request), + ); + + return new RecurringInvoiceResource($recurringInvoice); + } + + /** + * Drop several schedules at once. + * + * The submitted ids are narrowed to the acting company before anything is + * removed, so ids belonging elsewhere are quietly passed over. Invoices + * already minted by a dropped schedule survive it — they are merely cut + * loose from the parent. + */ + public function delete(Request $request) + { + $this->authorize('delete multiple recurring invoices'); + + $ids = RecurringInvoice::whereCompany() + ->whereIn('id', $request->input('ids')) + ->pluck('id'); + + $this->recurringInvoiceService->delete($ids); + + return response()->json([ + 'success' => true, + ]); + } + + /** + * The submitted custom-field values, or nothing when the payload carries + * something the service cannot walk. + */ + private function customFields(RecurringInvoiceRequest $request): ?iterable + { + $values = $request->input('customFields'); + + return is_iterable($values) ? $values : null; + } +} diff --git a/app/Domains/Sales/Http/Controllers/Company/RecurringInvoiceFrequencyController.php b/app/Domains/Sales/Http/Controllers/Company/RecurringInvoiceFrequencyController.php new file mode 100644 index 00000000..536e25eb --- /dev/null +++ b/app/Domains/Sales/Http/Controllers/Company/RecurringInvoiceFrequencyController.php @@ -0,0 +1,36 @@ +input('frequency'), + $request->input('starts_at'), + ); + + return response()->json([ + 'success' => true, + 'next_invoice_at' => $nextRun, + ]); + } +} diff --git a/app/Domains/Sales/Http/Controllers/Company/SerialNumberController.php b/app/Domains/Sales/Http/Controllers/Company/SerialNumberController.php new file mode 100644 index 00000000..97fb8e41 --- /dev/null +++ b/app/Domains/Sales/Http/Controllers/Company/SerialNumberController.php @@ -0,0 +1,92 @@ +setCompany($request->header('company')) + ->setCustomer($request->userId); + + // Invoices and credit notes live in one table, so each is pinned to its + // own row type: the preview must never count the other kind's rows. + switch ($request->key) { + case 'invoice': + $serial->setModel($invoice) + ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]); + + break; + + case 'credit_note': + $serial->setModel($invoice) + ->setSettingKey('credit_note_number_format') + ->setSequenceScope(['type' => Invoice::TYPE_CREDIT_NOTE]); + + break; + + case 'estimate': + $serial->setModel($estimate); + + break; + + case 'payment': + $serial->setModel($payment); + + break; + + default: + return response()->json([ + 'success' => false, + ]); + } + + try { + $nextNumber = $serial->setModelObject($request->model_id) + ->getNextNumber($request->input('format')); + } catch (\Exception $exception) { + return response()->json([ + 'success' => false, + 'message' => $exception->getMessage(), + ]); + } + + return response()->json([ + 'success' => true, + 'nextNumber' => $nextNumber, + ]); + } + + /** + * List the tokens a submitted format string is made of. + */ + public function placeholders(Request $request): JsonResponse + { + $format = $request->input('format'); + + return response()->json([ + 'success' => true, + 'placeholders' => $format ? SerialNumberService::getPlaceholders($format) : [], + ]); + } +} diff --git a/app/Domains/Sales/Http/Controllers/CustomerPortal/AcceptEstimateController.php b/app/Domains/Sales/Http/Controllers/CustomerPortal/AcceptEstimateController.php new file mode 100644 index 00000000..c4f12cd6 --- /dev/null +++ b/app/Domains/Sales/Http/Controllers/CustomerPortal/AcceptEstimateController.php @@ -0,0 +1,39 @@ +id(); + + $estimate = $company->estimates()->whereCustomer($contact)->where('id', $id)->first(); + + if ($estimate === null) { + return response()->json(['error' => 'estimate_not_found'], Response::HTTP_NOT_FOUND); + } + + $verdict = $request->only('status'); + + $estimate->update($verdict); + + return EstimateResource::make($estimate); + } +} diff --git a/app/Domains/Sales/Http/Controllers/CustomerPortal/EstimatePdfController.php b/app/Domains/Sales/Http/Controllers/CustomerPortal/EstimatePdfController.php new file mode 100644 index 00000000..ab791155 --- /dev/null +++ b/app/Domains/Sales/Http/Controllers/CustomerPortal/EstimatePdfController.php @@ -0,0 +1,92 @@ +documentBehind($emailLog); + + $this->recordReading($estimate); + + return $estimate->getGeneratedPDFOrStream('estimate'); + } + + /** + * Serve the same offer as JSON for the viewer shell. + * + * Note the payload is the back-office representation, not the trimmed + * portal one its invoice counterpart uses. Left as it stands. + */ + public function getEstimate(EmailLog $emailLog) + { + return EstimateResource::make($this->documentBehind($emailLog)); + } + + /** + * Trade an email-log token for the offer it was issued for. + * + * Holding the token is the whole credential, so the guard is narrow: the + * log must point at an offer, and the link must still be inside the + * company's expiry window. + */ + private function documentBehind(EmailLog $emailLog): Estimate + { + $document = $emailLog->mailable; + + if (! $document instanceof Estimate) { + abort(404); + } + + if ($emailLog->isExpired()) { + abort(403, 'Link Expired.'); + } + + return $document; + } + + /** + * Promote an offer that is still awaiting a reader, and tell the issuer + * about it when they asked to be told. + */ + private function recordReading(Estimate $estimate): void + { + $unread = [Estimate::STATUS_SENT, Estimate::STATUS_DRAFT]; + + if (! in_array($estimate->status, $unread)) { + return; + } + + $estimate->update(['status' => Estimate::STATUS_VIEWED]); + + $wanted = CompanySetting::getSetting('notify_estimate_viewed', $estimate->company_id); + + if ($wanted != 'YES') { + return; + } + + $payload = [ + 'estimate' => Estimate::findOrFail($estimate->id)->toArray(), + 'user' => Customer::find($estimate->customer_id)->toArray(), + ]; + + $mailbox = CompanySetting::getSetting('notification_email', $estimate->company_id); + + Mail::to($mailbox)->send(new EstimateViewedMail($payload)); + } +} diff --git a/app/Domains/Sales/Http/Controllers/CustomerPortal/EstimatesController.php b/app/Domains/Sales/Http/Controllers/CustomerPortal/EstimatesController.php new file mode 100644 index 00000000..092eb029 --- /dev/null +++ b/app/Domains/Sales/Http/Controllers/CustomerPortal/EstimatesController.php @@ -0,0 +1,78 @@ +has('limit')) { + $perPage = $request->limit; + } + + $contact = Auth::guard('customer')->id(); + + $query = Estimate::with(['items', 'customer', 'taxes', 'creator']) + ->where('status', '<>', Estimate::STATUS_DRAFT) + ->whereCustomer($contact); + + $query->applyFilters($request->only([ + 'status', + 'estimate_number', + 'from_date', + 'to_date', + 'orderByField', + 'orderBy', + ])); + + $page = $query->latest()->paginateData($perPage); + + $visible = Estimate::query() + ->where('status', '<>', Estimate::STATUS_DRAFT) + ->whereCustomer($contact) + ->count(); + + return EstimateResource::collection($page) + ->additional(['meta' => [ + 'estimateTotalCount' => $visible, + ]]); + } + + /** + * Hand back a single offer, looked up inside the portal's company and + * narrowed to the signed-in contact so ids cannot be probed. + * + * @param string $id + * @return Response + */ + public function show(Company $company, $id) + { + $contact = Auth::guard('customer')->id(); + + $estimate = $company->estimates()->whereCustomer($contact)->where('id', $id)->first(); + + if ($estimate === null) { + return response()->json(['error' => 'estimate_not_found'], Response::HTTP_NOT_FOUND); + } + + return EstimateResource::make($estimate); + } +} diff --git a/app/Domains/Sales/Http/Controllers/CustomerPortal/InvoicePdfController.php b/app/Domains/Sales/Http/Controllers/CustomerPortal/InvoicePdfController.php new file mode 100644 index 00000000..efb5661d --- /dev/null +++ b/app/Domains/Sales/Http/Controllers/CustomerPortal/InvoicePdfController.php @@ -0,0 +1,104 @@ +documentBehind($emailLog); + + $this->recordReading($invoice); + + if ($request->has('pdf')) { + return $invoice->getGeneratedPDFOrStream('invoice'); + } + + $issuer = $invoice->company_id; + + return view('app')->with([ + 'customer_logo' => get_company_setting('customer_portal_logo', $issuer), + 'current_theme' => get_company_setting('customer_portal_theme', $issuer), + ]); + } + + /** + * Serve the same document as JSON for the viewer shell, in the trimmed + * portal shape. + */ + public function getInvoice(EmailLog $emailLog) + { + return InvoiceResource::make($this->documentBehind($emailLog)); + } + + /** + * Trade an email-log token for the document it was issued for. + * + * Holding the token is the whole credential, so the guard is narrow. The + * log must point at a billing document (a token minted for some other + * kind of mail must not disclose one, however the ids line up), and the + * link must still be inside the company's expiry window. + */ + private function documentBehind(EmailLog $emailLog): Invoice + { + $document = $emailLog->mailable; + + if (! $document instanceof Invoice) { + abort(404); + } + + if ($emailLog->isExpired()) { + abort(403, 'Link Expired.'); + } + + return $document; + } + + /** + * Promote a document that is still awaiting a reader, and tell the issuer + * about it when they asked to be told. + */ + private function recordReading(Invoice $invoice): void + { + $unread = [Invoice::STATUS_SENT, Invoice::STATUS_DRAFT]; + + if (! in_array($invoice->status, $unread)) { + return; + } + + $invoice->update([ + 'status' => Invoice::STATUS_VIEWED, + 'viewed' => true, + ]); + + $wanted = CompanySetting::getSetting('notify_invoice_viewed', $invoice->company_id); + + if ($wanted != 'YES') { + return; + } + + $payload = [ + 'invoice' => Invoice::findOrFail($invoice->id)->toArray(), + 'user' => Customer::find($invoice->customer_id)->toArray(), + ]; + + $mailbox = CompanySetting::getSetting('notification_email', $invoice->company_id); + + Mail::to($mailbox)->send(new InvoiceViewedMail($payload)); + } +} diff --git a/app/Domains/Sales/Http/Controllers/CustomerPortal/InvoicesController.php b/app/Domains/Sales/Http/Controllers/CustomerPortal/InvoicesController.php new file mode 100644 index 00000000..df394803 --- /dev/null +++ b/app/Domains/Sales/Http/Controllers/CustomerPortal/InvoicesController.php @@ -0,0 +1,75 @@ +has('limit')) { + $perPage = $request->limit; + } + + $contact = Auth::guard('customer')->id(); + $filters = $request->all(); + + $page = Invoice::with(['items', 'customer', 'creator', 'taxes']) + ->where('status', '<>', Invoice::STATUS_DRAFT) + ->applyFilters($filters) + ->whereCustomer($contact) + ->latest() + ->paginateData($perPage); + + // The counter tallies issued documents alone. A credit note reverses + // an invoice rather than adding one, so it is left out of the total + // even though it is listed among the rows above. + $received = Invoice::query() + ->where('type', Invoice::TYPE_INVOICE) + ->where('status', '<>', Invoice::STATUS_DRAFT) + ->whereCustomer($contact) + ->count(); + + return InvoiceResource::collection($page) + ->additional(['meta' => [ + 'invoiceTotalCount' => $received, + ]]); + } + + /** + * Hand back a single billing document, looked up inside the portal's + * company and narrowed to the signed-in contact. + * + * @param string $id + * @return Response + */ + public function show(Company $company, $id) + { + $contact = Auth::guard('customer')->id(); + + $invoice = $company->invoices()->whereCustomer($contact)->where('id', $id)->first(); + + if ($invoice === null) { + return response()->json(['error' => 'invoice_not_found'], Response::HTTP_NOT_FOUND); + } + + return InvoiceResource::make($invoice); + } +} diff --git a/app/Domains/Sales/Http/Requests/DeleteEstimatesRequest.php b/app/Domains/Sales/Http/Requests/DeleteEstimatesRequest.php new file mode 100644 index 00000000..8cb973ff --- /dev/null +++ b/app/Domains/Sales/Http/Requests/DeleteEstimatesRequest.php @@ -0,0 +1,33 @@ + + */ + public function rules(): array + { + return [ + 'ids' => 'required', + 'ids.*' => ['required', Rule::exists('estimates', 'id')], + ]; + } +} diff --git a/app/Domains/Sales/Http/Requests/DeleteInvoiceRequest.php b/app/Domains/Sales/Http/Requests/DeleteInvoiceRequest.php new file mode 100644 index 00000000..700075ff --- /dev/null +++ b/app/Domains/Sales/Http/Requests/DeleteInvoiceRequest.php @@ -0,0 +1,46 @@ + + */ + public function rules(): array + { + $batch = (array) $this->input('ids', []); + + return [ + 'ids' => 'required', + 'ids.*' => [ + 'required', + Rule::exists('invoices', 'id'), + new RelationNotExist(Invoice::class, 'payments'), + new CreditNoteDeletedTogether($batch), + ], + ]; + } +} diff --git a/app/Domains/Sales/Http/Requests/EstimatesRequest.php b/app/Domains/Sales/Http/Requests/EstimatesRequest.php new file mode 100644 index 00000000..99a52dd6 --- /dev/null +++ b/app/Domains/Sales/Http/Requests/EstimatesRequest.php @@ -0,0 +1,140 @@ + + */ + public function rules(): array + { + return [ + 'estimate_date' => 'required', + 'expiry_date' => 'nullable', + 'customer_id' => 'required', + 'estimate_number' => ['required', $this->uniqueNumber()], + 'exchange_rate' => $this->foreignCurrency() ? 'required' : 'nullable', + 'discount' => 'numeric|required', + 'discount_val' => 'integer|required', + 'sub_total' => 'integer|required', + 'total' => 'integer|numeric|max:999999999999|required', + 'tax' => 'required', + 'template_name' => ['required', new PdfTemplateExists('estimate')], + 'items' => 'required|array', + 'items.*.description' => 'nullable', + 'items.*' => 'required|max:255', + 'items.*.name' => 'required', + 'items.*.quantity' => 'numeric|required', + 'items.*.price' => 'integer|required', + ]; + } + + public function withValidator(Validator $validator): void + { + $this->validateDocumentTaxPlaceholders($validator); + } + + /** + * The stored attributes for a create or an update. + * + * Totals are recomputed here from the submitted lines (GHSA-8c69): whatever + * sub_total / total / tax the client sent is discarded. The document is + * always denominated in the customer's currency. + * + * @return array + */ + public function getEstimatePayload() + { + $companyId = $this->header('company'); + $rate = CompanySetting::getSetting('currency', $companyId) != $this->currency_id + ? $this->exchange_rate + : 1; + + $perItemTax = CompanySetting::getSetting('tax_per_item', $companyId) ?? 'NO '; + $perItemDiscount = CompanySetting::getSetting('discount_per_item', $companyId) ?? 'NO'; + + $sums = DocumentTotals::compute( + $this->items ?? [], + $this->taxes ?? [], + $this->discount_val, + $perItemTax, + (bool) $this->tax_included, + $perItemDiscount + ); + + $sending = $this->has('estimateSend'); + + return collect($this->except(['items', 'taxes'])) + ->merge([ + 'creator_id' => $this->user()?->id, + 'status' => $sending ? Estimate::STATUS_SENT : Estimate::STATUS_DRAFT, + 'company_id' => $companyId, + 'tax_per_item' => $perItemTax, + 'discount_per_item' => $perItemDiscount, + 'sub_total' => $sums['sub_total'], + 'total' => $sums['total'], + 'tax' => $sums['tax'], + 'exchange_rate' => $rate, + 'base_discount_val' => $this->discount_val * $rate, + 'base_sub_total' => $sums['sub_total'] * $rate, + 'base_total' => $sums['total'] * $rate, + 'base_tax' => $sums['tax'] * $rate, + 'currency_id' => Customer::find($this->customer_id)->currency_id, + ]) + ->toArray(); + } + + /** + * Numbers are unique inside a company; on a replace the estimate being + * written is exempt from its own number. + */ + private function uniqueNumber(): Unique + { + $rule = Rule::unique('estimates')->where('company_id', $this->header('company')); + + return $this->isMethod('PUT') + ? $rule->ignore($this->route('estimate')->id) + : $rule; + } + + /** + * True when the billed customer settles in something other than the + * company's own currency, which makes a rate mandatory. + */ + private function foreignCurrency(): bool + { + $homeCurrency = CompanySetting::getSetting('currency', $this->header('company')); + $billed = Customer::find($this->customer_id); + + if (! $homeCurrency || ! $billed) { + return false; + } + + return (string) $billed->currency_id !== $homeCurrency; + } +} diff --git a/app/Domains/Sales/Http/Requests/InvoicesRequest.php b/app/Domains/Sales/Http/Requests/InvoicesRequest.php new file mode 100644 index 00000000..ce8c2db0 --- /dev/null +++ b/app/Domains/Sales/Http/Requests/InvoicesRequest.php @@ -0,0 +1,147 @@ + + */ + public function rules(): array + { + return [ + 'invoice_date' => 'required', + 'due_date' => 'nullable', + 'customer_id' => 'required', + 'invoice_number' => ['required', $this->uniqueNumber()], + 'exchange_rate' => $this->foreignCurrency() ? 'required' : 'nullable', + 'discount' => 'numeric|required', + 'discount_val' => 'integer|required', + 'sub_total' => 'numeric|required', + 'total' => 'numeric|max:999999999999|required', + 'tax' => 'required', + 'template_name' => ['required', new PdfTemplateExists('invoice')], + 'items' => 'required|array', + 'items.*' => 'required|max:255', + 'items.*.description' => 'nullable', + 'items.*.name' => 'required', + 'items.*.quantity' => 'numeric|required', + 'items.*.price' => 'numeric|required', + ]; + } + + public function withValidator(Validator $validator): void + { + $this->validateDocumentTaxPlaceholders($validator); + } + + /** + * The stored attributes for a create or an update. + * + * Totals are recomputed here from the submitted lines (GHSA-8c69): whatever + * sub_total / total / tax the client sent is discarded. The document is + * always denominated in the customer's currency, and it is never allowed to + * declare itself a credit note: those are minted by the credit-note service + * alone. + * + * @return array + */ + public function getInvoicePayload(): array + { + $companyId = $this->header('company'); + $rate = CompanySetting::getSetting('currency', $companyId) != $this->currency_id + ? $this->exchange_rate + : 1; + + $perItemTax = CompanySetting::getSetting('tax_per_item', $companyId) ?? 'NO '; + $perItemDiscount = CompanySetting::getSetting('discount_per_item', $companyId) ?? 'NO'; + $taxIncluded = (bool) $this->tax_included; + + $sums = DocumentTotals::compute( + $this->items ?? [], + $this->taxes ?? [], + $this->discount_val, + $perItemTax, + $taxIncluded, + $perItemDiscount + ); + + return array_merge($this->except(['items', 'taxes']), [ + 'creator_id' => $this->user()?->id, + 'type' => Invoice::TYPE_INVOICE, + 'related_invoice_id' => null, + 'credit_reason' => null, + 'status' => $this->exists('invoiceSend') ? Invoice::STATUS_SENT : Invoice::STATUS_DRAFT, + 'paid_status' => Invoice::STATUS_UNPAID, + 'company_id' => $companyId, + 'tax_per_item' => $perItemTax, + 'discount_per_item' => $perItemDiscount, + 'sub_total' => $sums['sub_total'], + 'total' => $sums['total'], + 'tax' => $sums['tax'], + 'due_amount' => $sums['total'], + 'sent' => (bool) $this->sent, + 'viewed' => (bool) $this->viewed, + 'exchange_rate' => $rate, + 'base_total' => $sums['total'] * $rate, + 'base_discount_val' => $this->discount_val * $rate, + 'base_sub_total' => $sums['sub_total'] * $rate, + 'base_tax' => $sums['tax'] * $rate, + 'base_due_amount' => $sums['total'] * $rate, + 'currency_id' => Customer::find($this->customer_id)->currency_id, + ]); + } + + /** + * Numbers are unique inside a company; on a replace the invoice being + * written is exempt from its own number. + */ + private function uniqueNumber(): Unique + { + $rule = Rule::unique('invoices')->where('company_id', $this->header('company')); + + return $this->isMethod('PUT') + ? $rule->ignore($this->route('invoice')->id) + : $rule; + } + + /** + * True when the billed customer settles in something other than the + * company's own currency, which makes a rate mandatory. + */ + private function foreignCurrency(): bool + { + $homeCurrency = CompanySetting::getSetting('currency', $this->header('company')); + $billed = Customer::find($this->customer_id); + + if (! $homeCurrency || ! $billed) { + return false; + } + + return (string) $billed->currency_id !== $homeCurrency; + } +} diff --git a/app/Domains/Sales/Http/Requests/RecurringInvoiceRequest.php b/app/Domains/Sales/Http/Requests/RecurringInvoiceRequest.php new file mode 100644 index 00000000..f7fec5d2 --- /dev/null +++ b/app/Domains/Sales/Http/Requests/RecurringInvoiceRequest.php @@ -0,0 +1,182 @@ +header('company')); + + $rules = [ + 'starts_at' => [ + 'required', + ], + 'send_automatically' => [ + 'required', + 'boolean', + ], + 'customer_id' => [ + 'required', + ], + 'exchange_rate' => [ + 'nullable', + ], + 'discount' => [ + 'numeric', + 'required', + ], + 'discount_val' => [ + 'integer', + 'required', + ], + 'sub_total' => [ + 'integer', + 'required', + ], + 'total' => [ + 'integer', + 'max:999999999999', + 'required', + ], + 'tax' => [ + 'required', + ], + 'status' => [ + 'required', + ], + 'frequency' => [ + 'required', + ], + 'limit_by' => [ + 'required', + ], + 'limit_count' => [ + 'required_if:limit_by,COUNT', + ], + 'limit_date' => [ + 'required_if:limit_by,DATE', + ], + 'items' => [ + 'required', + ], + 'items.*' => [ + 'required', + ], + 'items.*.description' => [ + 'nullable', + ], + ]; + + // A contact billed in some other currency than the company's turns the + // otherwise optional rate into a hard requirement. The contact is + // looked up by bare id, so one belonging to another company answers + // here just the same. + $contact = Customer::find($this->customer_id); + + if ($contact && $homeCurrency && (string) $contact->currency_id !== $homeCurrency) { + $rules['exchange_rate'] = [ + 'required', + ]; + } + + return $rules; + } + + /** + * Reject any per-item tax row that carries an amount without a type. + */ + public function withValidator(Validator $validator): void + { + $this->validateDocumentTaxPlaceholders($validator); + } + + /** + * Fold the submission into the columns of the schedule row. + * + * The submitted sub-total, tax and grand total are thrown away and worked + * out again from the line items, because every invoice this schedule mints + * inherits them. The stored currency is always the contact's; the + * submitted currency id only decides whether an exchange rate is carried + * or pinned at one. + */ + public function getRecurringInvoicePayload() + { + $company = $this->header('company'); + + $companyCurrency = CompanySetting::getSetting('currency', $company); + $submittedCurrency = $this->currency_id; + $rate = $companyCurrency != $submittedCurrency ? $this->exchange_rate : 1; + $contactCurrency = Customer::find($this->customer_id)->currency_id; + + $nextRun = RecurringInvoice::getNextInvoiceDate($this->frequency, $this->starts_at); + + $perItemTax = CompanySetting::getSetting('tax_per_item', $company) ?? 'NO '; + $perItemDiscount = CompanySetting::getSetting('discount_per_item', $company) ?? 'NO'; + + $totals = DocumentTotals::compute( + $this->items ?? [], + $this->taxes ?? [], + $this->discount_val, + $perItemTax, + (bool) $this->tax_included, + $perItemDiscount + ); + + $submitted = collect($this->except('items', 'taxes')); + + return $submitted + ->merge([ + 'creator_id' => $this->user()->id, + 'company_id' => $company, + 'next_invoice_at' => $nextRun, + 'tax_per_item' => $perItemTax, + 'discount_per_item' => $perItemDiscount, + 'sub_total' => $totals['sub_total'], + 'total' => $totals['total'], + 'tax' => $totals['tax'], + 'due_amount' => $totals['total'], + 'exchange_rate' => $rate, + 'base_sub_total' => $totals['sub_total'] * $rate, + 'base_total' => $totals['total'] * $rate, + 'base_tax' => $totals['tax'] * $rate, + 'currency_id' => $contactCurrency, + ]) + ->toArray(); + } +} diff --git a/app/Domains/Sales/Http/Requests/SendEstimatesRequest.php b/app/Domains/Sales/Http/Requests/SendEstimatesRequest.php new file mode 100644 index 00000000..05472b0f --- /dev/null +++ b/app/Domains/Sales/Http/Requests/SendEstimatesRequest.php @@ -0,0 +1,35 @@ + + */ + public function rules(): array + { + return [ + 'subject' => 'required', + 'body' => 'required', + 'from' => 'required', + 'to' => 'required', + 'cc' => 'nullable', + 'bcc' => 'nullable', + ]; + } +} diff --git a/app/Domains/Sales/Http/Requests/SendInvoiceRequest.php b/app/Domains/Sales/Http/Requests/SendInvoiceRequest.php new file mode 100644 index 00000000..343f5f4d --- /dev/null +++ b/app/Domains/Sales/Http/Requests/SendInvoiceRequest.php @@ -0,0 +1,35 @@ + + */ + public function rules(): array + { + return [ + 'body' => 'required', + 'subject' => 'required', + 'from' => 'required', + 'to' => 'required', + 'cc' => 'nullable', + 'bcc' => 'nullable', + ]; + } +} diff --git a/app/Domains/Sales/Http/Resources/CustomerPortal/EstimateCollection.php b/app/Domains/Sales/Http/Resources/CustomerPortal/EstimateCollection.php new file mode 100644 index 00000000..e114f7ab --- /dev/null +++ b/app/Domains/Sales/Http/Resources/CustomerPortal/EstimateCollection.php @@ -0,0 +1,23 @@ +resource; + + return [ + 'id' => $item->id, + 'name' => $item->name, + 'description' => $item->description, + 'discount_type' => $item->discount_type, + 'quantity' => $item->quantity, + 'unit_name' => $item->unit_name, + 'discount' => $item->discount, + 'discount_val' => $item->discount_val, + 'price' => $item->price, + 'tax' => $item->tax, + 'total' => $item->total, + 'item_id' => $item->item_id, + 'estimate_id' => $item->estimate_id, + 'company_id' => $item->company_id, + 'exchange_rate' => $item->exchange_rate, + 'base_discount_val' => $item->base_discount_val, + 'base_price' => $item->base_price, + 'base_tax' => $item->base_tax, + 'base_total' => $item->base_total, + 'taxes' => $this->when( + $item->taxes()->exists(), + fn () => TaxResource::collection($item->taxes) + ), + 'fields' => $this->when( + $item->fields()->exists(), + fn () => CustomFieldValueResource::collection($item->fields) + ), + ]; + } +} diff --git a/app/Domains/Sales/Http/Resources/CustomerPortal/EstimateResource.php b/app/Domains/Sales/Http/Resources/CustomerPortal/EstimateResource.php new file mode 100644 index 00000000..d8573a93 --- /dev/null +++ b/app/Domains/Sales/Http/Resources/CustomerPortal/EstimateResource.php @@ -0,0 +1,88 @@ +resource; + + return [ + 'id' => $estimate->id, + 'estimate_date' => $estimate->estimate_date, + 'expiry_date' => $estimate->expiry_date, + 'estimate_number' => $estimate->estimate_number, + 'status' => $estimate->status, + 'reference_number' => $estimate->reference_number, + 'tax_per_item' => $estimate->tax_per_item, + 'discount_per_item' => $estimate->discount_per_item, + 'notes' => $estimate->notes, + 'discount' => $estimate->discount, + 'discount_type' => $estimate->discount_type, + 'discount_val' => $estimate->discount_val, + 'sub_total' => $estimate->sub_total, + 'total' => $estimate->total, + 'tax' => $estimate->tax, + 'unique_hash' => $estimate->unique_hash, + 'template_name' => $estimate->template_name, + 'customer_id' => $estimate->customer_id, + 'exchange_rate' => $estimate->exchange_rate, + 'base_discount_val' => $estimate->base_discount_val, + 'base_sub_total' => $estimate->base_sub_total, + 'base_total' => $estimate->base_total, + 'base_tax' => $estimate->base_tax, + 'currency_id' => $estimate->currency_id, + 'formatted_expiry_date' => $estimate->formattedExpiryDate, + 'formatted_estimate_date' => $estimate->formattedEstimateDate, + 'estimate_pdf_url' => $estimate->estimatePdfUrl, + 'items' => $this->when( + $estimate->items()->exists(), + fn () => EstimateItemResource::collection($estimate->items) + ), + 'customer' => $this->when( + $estimate->customer()->exists(), + fn () => new CustomerResource($estimate->customer) + ), + 'taxes' => $this->when( + $estimate->taxes()->exists(), + fn () => TaxResource::collection($estimate->taxes) + ), + 'fields' => $this->when( + $estimate->fields()->exists(), + fn () => CustomFieldValueResource::collection($estimate->fields) + ), + 'company' => $this->when( + $estimate->company()->exists(), + fn () => new CompanyResource($estimate->company) + ), + 'currency' => $this->when( + $estimate->currency()->exists(), + fn () => new CurrencyResource($estimate->currency) + ), + ]; + } +} diff --git a/app/Domains/Sales/Http/Resources/CustomerPortal/InvoiceCollection.php b/app/Domains/Sales/Http/Resources/CustomerPortal/InvoiceCollection.php new file mode 100644 index 00000000..f842de3c --- /dev/null +++ b/app/Domains/Sales/Http/Resources/CustomerPortal/InvoiceCollection.php @@ -0,0 +1,24 @@ +resource; + + return [ + 'id' => $item->id, + 'name' => $item->name, + 'description' => $item->description, + 'discount_type' => $item->discount_type, + 'price' => $item->price, + 'quantity' => $item->quantity, + 'unit_name' => $item->unit_name, + 'discount' => $item->discount, + 'discount_val' => $item->discount_val, + 'tax' => $item->tax, + 'total' => $item->total, + 'invoice_id' => $item->invoice_id, + 'item_id' => $item->item_id, + 'company_id' => $item->company_id, + 'base_price' => $item->base_price, + 'exchange_rate' => $item->exchange_rate, + 'base_discount_val' => $item->base_discount_val, + 'base_tax' => $item->base_tax, + 'base_total' => $item->base_total, + 'recurring_invoice_id' => $item->recurring_invoice_id, + 'taxes' => $this->when( + $item->taxes()->exists(), + fn () => TaxResource::collection($item->taxes) + ), + 'fields' => $this->when( + $item->fields()->exists(), + fn () => CustomFieldValueResource::collection($item->fields) + ), + ]; + } +} diff --git a/app/Domains/Sales/Http/Resources/CustomerPortal/InvoiceResource.php b/app/Domains/Sales/Http/Resources/CustomerPortal/InvoiceResource.php new file mode 100644 index 00000000..5dc4a826 --- /dev/null +++ b/app/Domains/Sales/Http/Resources/CustomerPortal/InvoiceResource.php @@ -0,0 +1,101 @@ +resource; + + return [ + 'id' => $invoice->id, + 'invoice_date' => $invoice->invoice_date, + 'due_date' => $invoice->due_date, + 'invoice_number' => $invoice->invoice_number, + 'reference_number' => $invoice->reference_number, + 'status' => $invoice->status, + 'paid_status' => $invoice->paid_status, + 'tax_per_item' => $invoice->tax_per_item, + 'discount_per_item' => $invoice->discount_per_item, + 'notes' => $invoice->getNotes(), + 'discount_type' => $invoice->discount_type, + 'discount' => $invoice->discount, + 'discount_val' => $invoice->discount_val, + 'sub_total' => $invoice->sub_total, + 'total' => $invoice->total, + 'tax' => $invoice->tax, + 'due_amount' => $invoice->due_amount, + 'sent' => $invoice->sent, + 'viewed' => $invoice->viewed, + 'unique_hash' => $invoice->unique_hash, + 'template_name' => $invoice->template_name, + 'customer_id' => $invoice->customer_id, + 'recurring_invoice_id' => $invoice->recurring_invoice_id, + 'sequence_number' => $invoice->sequence_number, + 'base_discount_val' => $invoice->base_discount_val, + 'base_sub_total' => $invoice->base_sub_total, + 'base_total' => $invoice->base_total, + 'base_tax' => $invoice->base_tax, + 'base_due_amount' => $invoice->base_due_amount, + 'currency_id' => $invoice->currency_id, + 'formatted_created_at' => $invoice->formattedCreatedAt, + 'formatted_notes' => $invoice->formattedNotes, + 'invoice_pdf_url' => $invoice->invoicePdfUrl, + 'formatted_invoice_date' => $invoice->formattedInvoiceDate, + 'formatted_due_date' => $invoice->formattedDueDate, + 'payment_module_enabled' => $invoice->payment_module_enabled, + 'overdue' => $invoice->overdue, + 'items' => $this->when( + $invoice->items()->exists(), + fn () => InvoiceItemResource::collection($invoice->items) + ), + 'customer' => $this->when( + $invoice->customer()->exists(), + fn () => new CustomerResource($invoice->customer) + ), + 'taxes' => $this->when( + $invoice->taxes()->exists(), + fn () => TaxResource::collection($invoice->taxes) + ), + 'fields' => $this->when( + $invoice->fields()->exists(), + fn () => CustomFieldValueResource::collection($invoice->fields) + ), + 'company' => $this->when( + $invoice->company()->exists(), + fn () => new CompanyResource($invoice->company) + ), + 'currency' => $this->when( + $invoice->currency()->exists(), + fn () => new CurrencyResource($invoice->currency) + ), + ]; + } +} diff --git a/app/Domains/Sales/Http/Resources/EstimateCollection.php b/app/Domains/Sales/Http/Resources/EstimateCollection.php new file mode 100644 index 00000000..2b417ef9 --- /dev/null +++ b/app/Domains/Sales/Http/Resources/EstimateCollection.php @@ -0,0 +1,23 @@ +resource; + + return [ + 'id' => $item->id, + 'name' => $item->name, + 'description' => $item->description, + 'discount_type' => $item->discount_type, + 'quantity' => $item->quantity, + 'unit_name' => $item->unit_name, + 'discount' => $item->discount, + 'discount_val' => $item->discount_val, + 'price' => $item->price, + 'tax' => $item->tax, + 'total' => $item->total, + 'item_id' => $item->item_id, + 'estimate_id' => $item->estimate_id, + 'company_id' => $item->company_id, + 'exchange_rate' => $item->exchange_rate, + 'base_discount_val' => $item->base_discount_val, + 'base_price' => $item->base_price, + 'base_tax' => $item->base_tax, + 'base_total' => $item->base_total, + 'taxes' => $this->when( + $item->taxes()->exists(), + fn () => TaxResource::collection($item->taxes) + ), + 'fields' => $this->when( + $item->fields()->exists(), + fn () => CustomFieldValueResource::collection($item->fields) + ), + ]; + } +} diff --git a/app/Domains/Sales/Http/Resources/EstimateResource.php b/app/Domains/Sales/Http/Resources/EstimateResource.php new file mode 100644 index 00000000..85009c3e --- /dev/null +++ b/app/Domains/Sales/Http/Resources/EstimateResource.php @@ -0,0 +1,99 @@ +resource; + + return [ + 'id' => $estimate->id, + 'estimate_date' => $estimate->estimate_date, + 'expiry_date' => $estimate->expiry_date, + 'estimate_number' => $estimate->estimate_number, + 'status' => $estimate->status, + 'reference_number' => $estimate->reference_number, + 'tax_per_item' => $estimate->tax_per_item, + 'tax_included' => $estimate->tax_included, + 'discount_per_item' => $estimate->discount_per_item, + 'notes' => $estimate->getNotes(), + 'discount' => $estimate->discount, + 'discount_type' => $estimate->discount_type, + 'discount_val' => $estimate->discount_val, + 'sub_total' => $estimate->sub_total, + 'total' => $estimate->total, + 'tax' => $estimate->tax, + 'unique_hash' => $estimate->unique_hash, + 'creator_id' => $estimate->creator_id, + 'template_name' => $estimate->template_name, + 'customer_id' => $estimate->customer_id, + 'exchange_rate' => $estimate->exchange_rate, + 'base_discount_val' => $estimate->base_discount_val, + 'base_sub_total' => $estimate->base_sub_total, + 'base_total' => $estimate->base_total, + 'base_tax' => $estimate->base_tax, + 'sequence_number' => $estimate->sequence_number, + 'currency_id' => $estimate->currency_id, + 'formatted_expiry_date' => $estimate->formattedExpiryDate, + 'formatted_estimate_date' => $estimate->formattedEstimateDate, + 'estimate_pdf_url' => $estimate->estimatePdfUrl, + 'sales_tax_type' => $estimate->sales_tax_type, + 'sales_tax_address_type' => $estimate->sales_tax_address_type, + 'items' => $this->when( + $estimate->items()->exists(), + fn () => EstimateItemResource::collection($estimate->items) + ), + 'customer' => $this->when( + $estimate->customer()->exists(), + fn () => new CustomerResource($estimate->customer) + ), + 'creator' => $this->when( + $estimate->creator()->exists(), + fn () => new UserResource($estimate->creator) + ), + 'taxes' => $this->when( + $estimate->taxes()->exists(), + fn () => TaxResource::collection($estimate->taxes) + ), + 'fields' => $this->when( + $estimate->fields()->exists(), + fn () => CustomFieldValueResource::collection($estimate->fields) + ), + 'company' => $this->when( + $estimate->company()->exists(), + fn () => new CompanyResource($estimate->company) + ), + 'currency' => $this->when( + $estimate->currency()->exists(), + fn () => new CurrencyResource($estimate->currency) + ), + ]; + } +} diff --git a/app/Domains/Sales/Http/Resources/InvoiceCollection.php b/app/Domains/Sales/Http/Resources/InvoiceCollection.php new file mode 100644 index 00000000..33f22c9b --- /dev/null +++ b/app/Domains/Sales/Http/Resources/InvoiceCollection.php @@ -0,0 +1,25 @@ +resource; + + return [ + 'id' => $item->id, + 'name' => $item->name, + 'description' => $item->description, + 'discount_type' => $item->discount_type, + 'price' => $item->price, + 'quantity' => $item->quantity, + 'unit_name' => $item->unit_name, + 'discount' => $item->discount, + 'discount_val' => $item->discount_val, + 'tax' => $item->tax, + 'total' => $item->total, + 'invoice_id' => $item->invoice_id, + 'item_id' => $item->item_id, + 'company_id' => $item->company_id, + 'base_price' => $item->base_price, + 'exchange_rate' => $item->exchange_rate, + 'base_discount_val' => $item->base_discount_val, + 'base_tax' => $item->base_tax, + 'base_total' => $item->base_total, + 'recurring_invoice_id' => $item->recurring_invoice_id, + 'taxes' => $this->when( + $item->taxes()->exists(), + fn () => TaxResource::collection($item->taxes) + ), + 'fields' => $this->when( + $item->fields()->exists(), + fn () => CustomFieldValueResource::collection($item->fields) + ), + ]; + } +} diff --git a/app/Domains/Sales/Http/Resources/InvoiceResource.php b/app/Domains/Sales/Http/Resources/InvoiceResource.php new file mode 100644 index 00000000..18c09281 --- /dev/null +++ b/app/Domains/Sales/Http/Resources/InvoiceResource.php @@ -0,0 +1,268 @@ +resource; + $creditNotesLoaded = $invoice->relationLoaded('creditNotes'); + + return [ + 'id' => $invoice->id, + 'invoice_date' => $invoice->invoice_date, + 'due_date' => $invoice->due_date, + 'invoice_number' => $invoice->invoice_number, + 'reference_number' => $invoice->reference_number, + 'type' => $invoice->type, + 'related_invoice_id' => $invoice->related_invoice_id, + 'status' => $invoice->status, + 'paid_status' => $invoice->paid_status, + 'tax_per_item' => $invoice->tax_per_item, + 'tax_included' => $invoice->tax_included, + 'discount_per_item' => $invoice->discount_per_item, + 'notes' => $invoice->notes, + 'discount_type' => $invoice->discount_type, + 'discount' => $invoice->discount, + 'discount_val' => $invoice->discount_val, + 'sub_total' => $invoice->sub_total, + 'total' => $invoice->total, + 'tax' => $invoice->tax, + 'due_amount' => $invoice->due_amount, + 'sent' => $invoice->sent, + 'viewed' => $invoice->viewed, + 'unique_hash' => $invoice->unique_hash, + 'template_name' => $invoice->template_name, + 'customer_id' => $invoice->customer_id, + 'recurring_invoice_id' => $invoice->recurring_invoice_id, + 'sequence_number' => $invoice->sequence_number, + 'exchange_rate' => $invoice->exchange_rate, + 'base_discount_val' => $invoice->base_discount_val, + 'base_sub_total' => $invoice->base_sub_total, + 'base_total' => $invoice->base_total, + 'creator_id' => $invoice->creator_id, + 'base_tax' => $invoice->base_tax, + 'base_due_amount' => $invoice->base_due_amount, + 'currency_id' => $invoice->currency_id, + 'formatted_created_at' => $invoice->formattedCreatedAt, + 'invoice_pdf_url' => $invoice->invoicePdfUrl, + 'formatted_invoice_date' => $invoice->formattedInvoiceDate, + 'formatted_due_date' => $invoice->formattedDueDate, + 'allow_edit' => $invoice->allow_edit, + 'payment_module_enabled' => $invoice->payment_module_enabled, + 'sales_tax_type' => $invoice->sales_tax_type, + 'sales_tax_address_type' => $invoice->sales_tax_address_type, + 'overdue' => $invoice->overdue, + + // Just enough of each reversing document for the UI to flag the + // invoice as cancelled and link through to the storno. Suppressed + // when there are none, so the key's presence is itself the signal. + 'credit_notes' => $this->when( + $creditNotesLoaded && $invoice->creditNotes->isNotEmpty(), + fn () => $this->creditNoteReferences() + ), + + // Written by the crediting flow only; the invoice form never sets it. + 'credit_reason' => $invoice->credit_reason, + + // How much has been credited off this invoice and whether that + // covers the document in full. Both read the same already-loaded + // relation the banner above uses, so neither costs a query. + 'credited_total' => $this->when( + $creditNotesLoaded, + fn () => $this->creditedTotal() + ), + 'credited_status' => $this->when( + $creditNotesLoaded, + fn () => $this->creditedStatus() + ), + + // Credited quantity per line of THIS invoice, which is what a + // partial-credit form needs in order to offer what is left. Needs + // the reversing documents' own lines, so it waits for those too. + 'credited_quantities' => $this->when( + $creditNotesLoaded + && $invoice->creditNotes->every(fn ($note) => $note->relationLoaded('items')), + fn () => $this->creditedQuantities() + ), + + // Settlement is reported through the allocation rows rather than a + // payment relation on the invoice itself. Loaded for the detail + // response only, so listings stay free of per-row payment queries. + 'payment_allocations' => $this->when( + $invoice->relationLoaded('allocations'), + fn () => $this->allocationSummaries() + ), + + 'items' => $this->when( + $invoice->items()->exists(), + fn () => InvoiceItemResource::collection($invoice->items) + ), + 'customer' => $this->when( + $invoice->customer()->exists(), + fn () => new CustomerResource($invoice->customer) + ), + 'creator' => $this->when( + $invoice->creator()->exists(), + fn () => new UserResource($invoice->creator) + ), + 'taxes' => $this->when( + $invoice->taxes()->exists(), + fn () => TaxResource::collection($invoice->taxes) + ), + 'fields' => $this->when( + $invoice->fields()->exists(), + fn () => CustomFieldValueResource::collection($invoice->fields) + ), + 'company' => $this->when( + $invoice->company()->exists(), + fn () => new CompanyResource($invoice->company) + ), + 'currency' => $this->when( + $invoice->currency()->exists(), + fn () => new CurrencyResource($invoice->currency) + ), + ]; + } + + /** + * Everything credited off this invoice, in cents, as a positive number. + * + * Credit notes store their amounts negated, so the loaded relation's sum is + * flipped back on the way out. + */ + protected function creditedTotal(): int + { + return -(int) $this->creditNotes->sum('total'); + } + + /** + * Identifier and number of each document reversing this invoice. + * + * Reindexed, because the loaded relation's keys are positions in the parent + * result set and would otherwise be published as object keys. + */ + private function creditNoteReferences(): Collection + { + return $this->creditNotes + ->map(fn ($note) => [ + 'id' => $note->id, + 'invoice_number' => $note->invoice_number, + ]) + ->values(); + } + + /** + * How far the crediting has gone: none of it, all of it, or part of it. + */ + private function creditedStatus(): string + { + $credited = $this->creditedTotal(); + + return match (true) { + $credited === 0 => 'NONE', + $credited === (int) $this->total => 'FULL', + default => 'PARTIAL', + }; + } + + /** + * Credited quantity per line of this invoice, keyed by the line's id. + * + * Reversing lines that do not point back at an original line contribute + * nothing. The result is handed over as an object rather than an array: the + * keys are line ids, and an all-numeric nested array would be reindexed + * into a list by the resource filter, throwing those ids away. + */ + private function creditedQuantities(): object + { + $quantities = []; + + foreach ($this->creditNotes as $note) { + foreach ($note->items as $line) { + $source = $line->source_invoice_item_id; + + if (! $source) { + continue; + } + + $quantities[$source] = ($quantities[$source] ?? 0) + (float) $line->quantity; + } + } + + return (object) $quantities; + } + + /** + * One row per payment allocated against this invoice. + * + * The paying document is nested only when it came along with the + * allocation; otherwise the row still reports the allocated amounts and + * leaves the payment null rather than fetching it. + */ + private function allocationSummaries(): Collection + { + return $this->allocations + ->map(fn ($allocation) => [ + 'id' => $allocation->id, + 'payment_id' => $allocation->payment_id, + 'amount' => $allocation->amount, + 'base_amount' => $allocation->base_amount, + 'payment' => $this->allocatedPayment($allocation), + ]) + ->values(); + } + + /** + * The paying document behind one allocation, when it is already loaded. + */ + private function allocatedPayment($allocation): ?array + { + if (! $allocation->relationLoaded('payment') || ! $allocation->payment) { + return null; + } + + return [ + 'id' => $allocation->payment->id, + 'payment_number' => $allocation->payment->payment_number, + 'formatted_payment_date' => $allocation->payment->formattedPaymentDate, + ]; + } +} diff --git a/app/Domains/Sales/Http/Resources/RecurringInvoiceCollection.php b/app/Domains/Sales/Http/Resources/RecurringInvoiceCollection.php new file mode 100644 index 00000000..0f836782 --- /dev/null +++ b/app/Domains/Sales/Http/Resources/RecurringInvoiceCollection.php @@ -0,0 +1,23 @@ +resource; + + return [ + 'id' => $recurring->id, + 'starts_at' => $recurring->starts_at, + 'formatted_starts_at' => $recurring->formattedStartsAt, + 'formatted_created_at' => $recurring->formattedCreatedAt, + 'formatted_next_invoice_at' => $recurring->formattedNextInvoiceAt, + 'formatted_limit_date' => $recurring->formattedLimitDate, + 'send_automatically' => $recurring->send_automatically, + 'customer_id' => $recurring->customer_id, + 'company_id' => $recurring->company_id, + 'creator_id' => $recurring->creator_id, + 'status' => $recurring->status, + 'next_invoice_at' => $recurring->next_invoice_at, + 'frequency' => $recurring->frequency, + 'limit_by' => $recurring->limit_by, + 'limit_count' => $recurring->limit_count, + 'limit_date' => $recurring->limit_date, + 'exchange_rate' => $recurring->exchange_rate, + 'tax_per_item' => $recurring->tax_per_item, + 'tax_included' => $recurring->tax_included, + 'discount_per_item' => $recurring->discount_per_item, + 'notes' => $recurring->notes, + 'discount_type' => $recurring->discount_type, + 'discount' => $recurring->discount, + 'discount_val' => $recurring->discount_val, + 'sub_total' => $recurring->sub_total, + 'total' => $recurring->total, + 'tax' => $recurring->tax, + 'due_amount' => $recurring->due_amount, + 'template_name' => $recurring->template_name, + 'sales_tax_type' => $recurring->sales_tax_type, + 'sales_tax_address_type' => $recurring->sales_tax_address_type, + 'fields' => $this->when( + $recurring->fields()->exists(), + fn () => CustomFieldValueResource::collection($recurring->fields) + ), + 'items' => $this->when( + $recurring->items()->exists(), + fn () => InvoiceItemResource::collection($recurring->items) + ), + 'customer' => $this->when( + $recurring->customer()->exists(), + fn () => new CustomerResource($recurring->customer) + ), + 'company' => $this->when( + $recurring->company()->exists(), + fn () => new CompanyResource($recurring->company) + ), + 'invoices' => $this->when( + $recurring->invoices()->exists(), + fn () => InvoiceResource::collection($recurring->invoices) + ), + 'taxes' => $this->when( + $recurring->taxes()->exists(), + fn () => TaxResource::collection($recurring->taxes) + ), + 'creator' => $this->when( + $recurring->creator()->exists(), + fn () => new UserResource($recurring->creator) + ), + 'currency' => $this->when( + $recurring->currency()->exists(), + fn () => new CurrencyResource($recurring->currency) + ), + ]; + } +} diff --git a/app/Domains/Sales/Jobs/GenerateEstimatePdfJob.php b/app/Domains/Sales/Jobs/GenerateEstimatePdfJob.php new file mode 100644 index 00000000..c08ea56e --- /dev/null +++ b/app/Domains/Sales/Jobs/GenerateEstimatePdfJob.php @@ -0,0 +1,54 @@ +estimate = $estimate; + $this->deleteExistingFile = $deleteExistingFile; + } + + /** + * Hands the work to the document itself and always reports success — the + * return value is a leftover of the queue contract; nothing reads it. + */ + public function handle(): int + { + $document = $this->estimate; + + $document->generatePDF('estimate', $document->estimate_number, $this->deleteExistingFile); + + return 0; + } +} diff --git a/app/Domains/Sales/Jobs/GenerateInvoicePdfJob.php b/app/Domains/Sales/Jobs/GenerateInvoicePdfJob.php new file mode 100644 index 00000000..5d59696d --- /dev/null +++ b/app/Domains/Sales/Jobs/GenerateInvoicePdfJob.php @@ -0,0 +1,54 @@ +invoice = $invoice; + $this->deleteExistingFile = $deleteExistingFile; + } + + /** + * Hands the work to the document itself and always reports success — the + * return value is a leftover of the queue contract; nothing reads it. + */ + public function handle(): int + { + $document = $this->invoice; + + $document->generatePDF('invoice', $document->invoice_number, $this->deleteExistingFile); + + return 0; + } +} diff --git a/app/Domains/Sales/Mail/EstimateViewedMail.php b/app/Domains/Sales/Mail/EstimateViewedMail.php new file mode 100644 index 00000000..63ef2518 --- /dev/null +++ b/app/Domains/Sales/Mail/EstimateViewedMail.php @@ -0,0 +1,48 @@ +data = $data; + } + + /** + * @return $this + */ + public function build() + { + return $this->subject(__('notification_view_estimate')) + ->from(config('mail.from.address'), config('mail.from.name')) + ->markdown('emails.viewed.estimate', [ + // Handed over as a list, not as a keyed array. The numeric + // keys that produces are inert: the view reads $data, which + // Laravel already supplies from the public property above. + 'data', + $this->data, + ]); + } +} diff --git a/app/Domains/Sales/Mail/InvoiceViewedMail.php b/app/Domains/Sales/Mail/InvoiceViewedMail.php new file mode 100644 index 00000000..070d0686 --- /dev/null +++ b/app/Domains/Sales/Mail/InvoiceViewedMail.php @@ -0,0 +1,48 @@ +data = $data; + } + + /** + * @return $this + */ + public function build() + { + return $this->subject(__('notification_view_invoice')) + ->from(config('mail.from.address'), config('mail.from.name')) + ->markdown('emails.viewed.invoice', [ + // Handed over as a list, not as a keyed array. The numeric + // keys that produces are inert: the view reads $data, which + // Laravel already supplies from the public property above. + 'data', + $this->data, + ]); + } +} diff --git a/app/Domains/Sales/Mail/SendEstimateMail.php b/app/Domains/Sales/Mail/SendEstimateMail.php new file mode 100644 index 00000000..5c7296bf --- /dev/null +++ b/app/Domains/Sales/Mail/SendEstimateMail.php @@ -0,0 +1,104 @@ +data = $data; + } + + /** + * @return $this + */ + public function build() + { + $this->data['url'] = route('estimate', [ + 'email_log' => $this->logDelivery(), + ]); + + $payload = $this->data; + + $message = $this->from($payload['from'], config('mail.from.name')) + ->subject($payload['subject']) + ->markdown('emails.send.estimate', [ + // Handed over as a list, not as a keyed array. The numeric + // keys that produces are inert: the view reads $data, which + // Laravel already supplies from the public property above. + 'data', + $this->data, + ]); + + $pdf = $payload['attach']['data']; + + if ($pdf) { + $message->attachData( + $pdf->output(), + $payload['estimate']['estimate_number'].'.pdf' + ); + } + + return $message; + } + + /** + * Record the outgoing message and give back the token that identifies it + * in a public link. + */ + private function logDelivery(): string + { + $payload = $this->data; + $alias = ModelIdentityMap::aliasFor(Estimate::class); + + $log = EmailLog::create([ + 'from' => $payload['from'], + 'to' => $payload['to'], + 'cc' => $payload['cc'] ?? null, + 'bcc' => $payload['bcc'] ?? null, + 'subject' => $payload['subject'], + 'body' => $payload['body'], + 'mailable_type' => $alias, + 'mailable_id' => $payload['estimate']['id'], + ]); + + $log->token = Hashids::connection(HashidConnection::EmailLog->value) + ->encode($log->id); + + $log->save(); + + return $log->token; + } +} diff --git a/app/Domains/Sales/Mail/SendInvoiceMail.php b/app/Domains/Sales/Mail/SendInvoiceMail.php new file mode 100644 index 00000000..1448b755 --- /dev/null +++ b/app/Domains/Sales/Mail/SendInvoiceMail.php @@ -0,0 +1,104 @@ +data = $data; + } + + /** + * @return $this + */ + public function build() + { + $this->data['url'] = route('invoice', [ + 'email_log' => $this->logDelivery(), + ]); + + $payload = $this->data; + + $message = $this->from($payload['from'], config('mail.from.name')) + ->subject($payload['subject']) + ->markdown('emails.send.invoice', [ + // Handed over as a list, not as a keyed array. The numeric + // keys that produces are inert: the view reads $data, which + // Laravel already supplies from the public property above. + 'data', + $this->data, + ]); + + $pdf = $payload['attach']['data']; + + if ($pdf) { + $message->attachData( + $pdf->output(), + $payload['invoice']['invoice_number'].'.pdf' + ); + } + + return $message; + } + + /** + * Record the outgoing message and give back the token that identifies it + * in a public link. + */ + private function logDelivery(): string + { + $payload = $this->data; + $alias = ModelIdentityMap::aliasFor(Invoice::class); + + $log = EmailLog::create([ + 'from' => $payload['from'], + 'to' => $payload['to'], + 'cc' => $payload['cc'] ?? null, + 'bcc' => $payload['bcc'] ?? null, + 'subject' => $payload['subject'], + 'body' => $payload['body'], + 'mailable_type' => $alias, + 'mailable_id' => $payload['invoice']['id'], + ]); + + $log->token = Hashids::connection(HashidConnection::EmailLog->value) + ->encode($log->id); + + $log->save(); + + return $log->token; + } +} diff --git a/app/Domains/Sales/Models/Estimate.php b/app/Domains/Sales/Models/Estimate.php new file mode 100644 index 00000000..bcbe1161 --- /dev/null +++ b/app/Domains/Sales/Models/Estimate.php @@ -0,0 +1,526 @@ + 'integer', + 'tax' => 'integer', + 'sub_total' => 'integer', + 'discount' => 'float', + 'discount_val' => 'integer', + 'exchange_rate' => 'float', + ]; + } + + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ + + /** + * Line items making up the offer. + */ + public function items(): HasMany + { + return $this->hasMany(EstimateItem::class, 'estimate_id', 'id'); + } + + /** + * Document-level tax rows, as opposed to the per-item ones hanging off the + * line items. + */ + public function taxes(): HasMany + { + return $this->hasMany(Tax::class, 'estimate_id', 'id'); + } + + /** + * Contact the offer was made to. + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class, 'customer_id', 'id'); + } + + /** + * Staff account that raised the offer. + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'creator_id', 'id'); + } + + /** + * Company the offer was issued under. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id', 'id'); + } + + /** + * Currency the stored amounts are denominated in. + */ + public function currency(): BelongsTo + { + return $this->belongsTo(Currency::class, 'currency_id', 'id'); + } + + /** + * Mail sent out for this offer, recorded through the polymorphic log. + */ + public function emailLogs(): MorphMany + { + return $this->morphMany(EmailLog::class, 'mailable', 'mailable_type', 'mailable_id', 'id'); + } + + /* + |-------------------------------------------------------------------------- + | Accessors + |-------------------------------------------------------------------------- + */ + + /** + * Shareable PDF address. Possession of the hash is the credential, so the + * link carries no company or customer context of its own. + */ + public function getEstimatePdfUrlAttribute() + { + $path = '/estimates/pdf/'.$this->unique_hash; + + return url($path); + } + + /** + * Expiry written in the company's configured date format and in the + * language the application is running in. + * + * @param mixed $value + */ + public function getFormattedExpiryDateAttribute($value) + { + $format = CompanySetting::getSetting('carbon_date_format', $this->company_id); + + return Carbon::parse($this->expiry_date)->translatedFormat($format); + } + + /** + * Issue date written the same way as the expiry above. + * + * @param mixed $value + */ + public function getFormattedEstimateDateAttribute($value) + { + $format = CompanySetting::getSetting('carbon_date_format', $this->company_id); + + return Carbon::parse($this->estimate_date)->translatedFormat($format); + } + + /* + |-------------------------------------------------------------------------- + | Scopes + |-------------------------------------------------------------------------- + */ + + /** + * Run every listed filter that carries a value. + * + * The order the filters are applied in is load-bearing: `estimate_id` is an + * OR (see whereEstimate), so it widens whatever has been narrowed down to + * that point and nothing added afterwards. Keep the sequence as it stands. + */ + public function scopeApplyFilters($query, array $filters) + { + $scopes = [ + 'search' => 'whereSearch', + 'estimate_number' => 'whereEstimateNumber', + 'status' => 'whereStatus', + 'estimate_id' => 'whereEstimate', + ]; + + foreach ($scopes as $filter => $scope) { + $value = $filters[$filter] ?? null; + + if ($value) { + $query->{$scope}($value); + } + } + + $from = $filters['from_date'] ?? null; + $to = $filters['to_date'] ?? null; + + if ($from && $to) { + $query->estimatesBetween( + Carbon::createFromFormat('Y-m-d', $from), + Carbon::createFromFormat('Y-m-d', $to) + ); + } + + $contact = $filters['customer_id'] ?? null; + + if ($contact) { + $query->whereCustomer($contact); + } + + $sortField = $filters['orderByField'] ?? null; + $sortDirection = $filters['orderBy'] ?? null; + + if ($sortField || $sortDirection) { + $query->whereOrder($sortField ?: 'sequence_number', $sortDirection ?: 'desc'); + } + } + + /** + * Restrict to offers issued inside the inclusive range. + */ + public function scopeEstimatesBetween($query, $start, $end) + { + $range = [$start->format('Y-m-d'), $end->format('Y-m-d')]; + + return $query->whereBetween($this->qualifyColumn('estimate_date'), $range); + } + + /** + * Exact match on the lifecycle status. + */ + public function scopeWhereStatus($query, $status) + { + return $query->where($this->qualifyColumn('status'), $status); + } + + /** + * Partial match on the document number. + */ + public function scopeWhereEstimateNumber($query, $estimateNumber) + { + return $query->where($this->qualifyColumn('estimate_number'), 'LIKE', '%'.$estimateNumber.'%'); + } + + /** + * Pull one specific offer back into the result set. + * + * This is an OR against an unqualified `id`, not a narrowing filter — it + * adds the row to whatever the other filters matched rather than + * intersecting with them. Preserved deliberately. + */ + public function scopeWhereEstimate($query, $estimate_id) + { + return $query->orWhere('id', $estimate_id); + } + + /** + * Keep only offers whose customer matches every whitespace-separated term, + * a term counting as matched when it appears in the display name, the + * contact person or the company name. + */ + public function scopeWhereSearch($query, $search) + { + $terms = explode(' ', $search); + + foreach ($terms as $term) { + $needle = '%'.$term.'%'; + + $query->whereHas('customer', function ($contact) use ($needle) { + $contact->where('name', 'LIKE', $needle) + ->orWhere('contact_name', 'LIKE', $needle) + ->orWhere('company_name', 'LIKE', $needle); + }); + } + } + + /** + * Sort by a caller-supplied column, sanitised before it reaches SQL. + */ + public function scopeWhereOrder($query, $orderByField, $orderBy) + { + return SafeOrderBy::apply($query, $orderByField, $orderBy); + } + + /** + * Narrow to the company the current request is acting on. + */ + public function scopeWhereCompany($query) + { + $active = request()->header('company'); + + return $query->where($this->qualifyColumn('company_id'), $active); + } + + /** + * Narrow to one contact's offers. + */ + public function scopeWhereCustomer($query, $customer_id) + { + return $query->where($this->qualifyColumn('customer_id'), $customer_id); + } + + /** + * Return the whole result set for the sentinel limit "all", otherwise a + * page of the requested size. + */ + public function scopePaginateData($query, $limit) + { + return $limit == 'all' ? $query->get() : $query->paginate($limit); + } + + /* + |-------------------------------------------------------------------------- + | PDF and mail + |-------------------------------------------------------------------------- + */ + + /** + * View data for the PDF template, assembled by the Sales domain. + */ + public function getPDFData(): mixed + { + $provider = app(EstimatePdfDataProvider::class); + + return $provider->getPdfData($this); + } + + /** + * Issuer's postal address as the PDF wants it, or false when the company + * has no address on file at all. + */ + public function getCompanyAddress(): string|false + { + if ($this->company && ! $this->company->address()->exists()) { + return false; + } + + return $this->renderAddress('estimate_company_address_format'); + } + + /** + * Where the goods would ship, or false when the contact keeps no shipping + * address. + */ + public function getCustomerShippingAddress(): string|false + { + if ($this->customer && ! $this->customer->shippingAddress()->exists()) { + return false; + } + + return $this->renderAddress('estimate_shipping_address_format'); + } + + /** + * Where the offer would be billed, or false when the contact keeps no + * billing address. + */ + public function getCustomerBillingAddress(): string|false + { + if ($this->customer && ! $this->customer->billingAddress()->exists()) { + return false; + } + + return $this->renderAddress('estimate_billing_address_format'); + } + + /** + * The notes field with its placeholders filled in and the resulting markup + * scrubbed before it reaches the renderer. + */ + public function getNotes(): string + { + $rendered = $this->getFormattedString($this->notes); + + return PdfHtmlSanitizer::sanitize($rendered); + } + + /** + * Whether the PDF should ride along with the mail. Anything other than the + * explicit opt-out means yes. + */ + public function getEmailAttachmentSetting(): bool + { + $setting = CompanySetting::getSetting('estimate_email_attachment', $this->company_id); + + return $setting != 'NO'; + } + + /** + * Fill the placeholders in a mail body, then drop any brace token that was + * left standing because nothing answered to it. + */ + public function getEmailBody(string $body): string + { + $placeholders = array_merge($this->getFieldsArray(), $this->getExtraFields()); + + $filled = strtr($body, $placeholders); + + return preg_replace('/{(.*?)}/', '', $filled); + } + + /** + * The placeholders this document type contributes on top of the shared + * company/contact set. + */ + public function getExtraFields(): array + { + $tokens = [ + 'ESTIMATE_DATE' => $this->formattedEstimateDate, + 'ESTIMATE_EXPIRY_DATE' => $this->formattedExpiryDate, + 'ESTIMATE_NUMBER' => $this->estimate_number, + 'ESTIMATE_REF_NUMBER' => $this->reference_number, + ]; + + $fields = []; + + foreach ($tokens as $token => $value) { + $fields['{'.$token.'}'] = $value; + } + + return $fields; + } + + /** + * The invoice template that corresponds to this offer's own template. + * + * The two families are named in parallel, so the mapping is a word swap. + * When the swapped name is not among the installed invoice templates the + * first one takes over. + */ + public function getInvoiceTemplateName(): string + { + $mapped = Str::replace('estimate', 'invoice', $this->template_name); + + // The second argument is the preview image format. Leaving it empty + // stops the helper from rendering a base64 thumbnail of every template + // when all that is wanted here are the names. + $available = array_column(PdfTemplateUtils::getFormattedTemplates('invoice', ''), 'name'); + + return in_array($mapped, $available) ? $mapped : 'invoice1'; + } + + /** + * Apply whatever the company wants done with an offer once it has been + * turned into an invoice: drop it, or mark it as accepted. Any other + * setting leaves the record alone. + */ + public function checkForEstimateConvertAction(): bool + { + $action = CompanySetting::getSetting('estimate_convert_action', $this->company_id); + + if ($action === 'delete_estimate') { + $this->delete(); + } + + if ($action === 'mark_estimate_as_accepted') { + $this->fill(['status' => self::STATUS_ACCEPTED])->save(); + } + + return true; + } + + /** + * Render one of the company's address layouts against this document. + */ + private function renderAddress(string $setting): string + { + $layout = CompanySetting::getSetting($setting, $this->company_id); + + return $this->getFormattedString($layout); + } +} diff --git a/app/Domains/Sales/Models/EstimateItem.php b/app/Domains/Sales/Models/EstimateItem.php new file mode 100644 index 00000000..dc3bbcba --- /dev/null +++ b/app/Domains/Sales/Models/EstimateItem.php @@ -0,0 +1,88 @@ + 'integer', + 'total' => 'integer', + 'discount' => 'float', + 'quantity' => 'float', + 'discount_val' => 'integer', + 'tax' => 'integer', + ]; + } + + /** + * Offer the line belongs to. + */ + public function estimate(): BelongsTo + { + return $this->belongsTo(Estimate::class, 'estimate_id', 'id'); + } + + /** + * Catalog entry the line was built from, when there was one. + */ + public function item(): BelongsTo + { + return $this->belongsTo(Item::class, 'item_id', 'id'); + } + + /** + * Taxes charged on this line, used when the document is in per-item tax + * mode. + */ + public function taxes(): HasMany + { + return $this->hasMany(Tax::class, 'estimate_item_id', 'id'); + } + + /** + * Narrow to one company. The column is left unqualified, as the callers + * pass a plain estimate-item query. + */ + public function scopeWhereCompany(Builder $query, int $company_id): void + { + $query->where('company_id', '=', $company_id); + } +} diff --git a/app/Domains/Sales/Models/Invoice.php b/app/Domains/Sales/Models/Invoice.php new file mode 100644 index 00000000..04b95e41 --- /dev/null +++ b/app/Domains/Sales/Models/Invoice.php @@ -0,0 +1,847 @@ + 'integer', + 'tax' => 'integer', + 'sub_total' => 'integer', + 'discount' => 'float', + 'discount_val' => 'integer', + 'exchange_rate' => 'float', + ]; + } + + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ + + /** + * Ledger entries written when the document is settled. + */ + public function transactions(): HasMany + { + return $this->hasMany(Transaction::class); + } + + /** + * Mail sent about this document. + */ + public function emailLogs(): MorphMany + { + return $this->morphMany(EmailLog::class, 'mailable'); + } + + /** + * Line items, snapshotted from the catalog at the time of writing. + */ + public function items(): HasMany + { + return $this->hasMany(InvoiceItem::class); + } + + /** + * Document-level applied taxes. + */ + public function taxes(): HasMany + { + return $this->hasMany(Tax::class); + } + + /** + * Individual slices of payments booked against this document. + */ + public function allocations(): HasMany + { + return $this->hasMany(PaymentAllocation::class); + } + + /** + * Payments touching this document, with the allocated amounts carried on + * the pivot. + */ + public function payments(): BelongsToMany + { + return $this->belongsToMany(Payment::class, 'payment_allocations') + ->withPivot(['amount', 'base_amount']) + ->withTimestamps(); + } + + /** + * Currency the document was issued in. + */ + public function currency(): BelongsTo + { + return $this->belongsTo(Currency::class); + } + + /** + * Company the document was raised under. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + /** + * Contact the document was raised for. + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class, 'customer_id'); + } + + /** + * Schedule that generated this document, when it was not raised by hand. + */ + public function recurringInvoice(): BelongsTo + { + return $this->belongsTo(RecurringInvoice::class); + } + + /** + * Staff account that raised the document. + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'creator_id'); + } + + /** + * The document this one reverses, null on anything but a credit note. + */ + public function relatedInvoice(): BelongsTo + { + return $this->belongsTo(Invoice::class, 'related_invoice_id'); + } + + /** + * Reversals raised against this document. + */ + public function creditNotes(): HasMany + { + return $this->hasMany(Invoice::class, 'related_invoice_id') + ->where('type', self::TYPE_CREDIT_NOTE); + } + + /* + |-------------------------------------------------------------------------- + | Accessors + |-------------------------------------------------------------------------- + */ + + /** + * Whether this document reverses another one. + */ + public function isCreditNote(): bool + { + return $this->type === self::TYPE_CREDIT_NOTE; + } + + /** + * Shareable link to the rendered PDF. Possession of the hash is the only + * credential the link needs. + */ + public function getInvoicePdfUrlAttribute() + { + return url('/invoices/pdf/'.$this->unique_hash); + } + + /** + * Whether the optional payments module is installed and switched on. + */ + public function getPaymentModuleEnabledAttribute() + { + return Module::has('Payments') ? Module::isEnabled('Payments') : false; + } + + /** + * Whether the document may still be altered. + * + * A credited invoice is immutable: its line item ids anchor the lines of + * every credit note that reverses it. Past that, the company's + * retrospective-edits setting decides, tightening in three steps from + * "sent and part paid" through "part paid" to "paid". + */ + public function getAllowEditAttribute() + { + if ($this->hasCreditNotes()) { + return false; + } + + $mode = CompanySetting::getSetting('retrospective_edits', $this->company_id); + + $collected = $this->paid_status === self::STATUS_PARTIALLY_PAID + || $this->paid_status === self::STATUS_PAID; + + $undelivered = [ + self::STATUS_DRAFT, + self::STATUS_SENT, + self::STATUS_VIEWED, + self::STATUS_COMPLETED, + ]; + + if ($mode == 'disable_on_invoice_sent') { + return ! (in_array($this->status, $undelivered) && $collected); + } + + if ($mode == 'disable_on_invoice_partial_paid') { + return ! $collected; + } + + if ($mode == 'disable_on_invoice_paid') { + return $this->paid_status !== self::STATUS_PAID; + } + + return true; + } + + /** + * The delivery status to fall back on when a document stops being complete: + * as far along as it had already travelled, and no further. + */ + public function getPreviousStatus(): string + { + if ($this->viewed) { + return self::STATUS_VIEWED; + } + + if ($this->sent) { + return self::STATUS_SENT; + } + + return self::STATUS_DRAFT; + } + + /** + * The note field with its placeholders resolved and its markup sanitised. + * + * @param mixed $value + */ + public function getFormattedNotesAttribute($value) + { + return $this->getNotes(); + } + + /** + * Creation timestamp in the company's configured date format. + * + * @param mixed $value + */ + public function getFormattedCreatedAtAttribute($value) + { + return Carbon::parse($this->created_at)->format($this->documentDateFormat()); + } + + /** + * Payment deadline in the company's configured date format, written in the + * language the application is running in. + * + * @param mixed $value + */ + public function getFormattedDueDateAttribute($value) + { + return Carbon::parse($this->due_date)->translatedFormat($this->documentDateFormat()); + } + + /** + * Outstanding balance rendered for print, in the document's currency, or + * in the company's for a document that never got one. + * + * @param mixed $value + */ + public function getFormattedDueAmountAttribute($value) + { + $currency = $this->currency ?: Currency::findOrFail( + CompanySetting::getSetting('currency', $this->company_id) + ); + + return format_money_pdf($this->due_amount, $currency); + } + + /** + * Issue date in the company's configured date format, written in the + * language the application is running in and carrying the time of day when + * the company asked for invoices to be timestamped. + * + * @param mixed $value + */ + public function getFormattedInvoiceDateAttribute($value) + { + $format = $this->documentDateFormat(); + + if (CompanySetting::getSetting('invoice_use_time', $this->company_id) === 'YES') { + $format .= ' '.CompanySetting::getSetting('carbon_time_format', $this->company_id); + } + + return Carbon::parse($this->invoice_date)->translatedFormat($format); + } + + /* + |-------------------------------------------------------------------------- + | Query scopes + |-------------------------------------------------------------------------- + */ + + /** + * Narrow to one delivery status. + */ + public function scopeWhereStatus($query, $status) + { + return $query->where($this->qualifyColumn('status'), $status); + } + + /** + * Narrow to one collection status. + */ + public function scopeWherePaidStatus($query, $status) + { + return $query->where($this->qualifyColumn('paid_status'), $status); + } + + /** + * Narrow to documents with money still outstanding. + * + * The status argument is accepted for call-site symmetry with the other + * status scopes and is deliberately unused: "due" is a fixed pair of + * collection statuses, not a value to match. + */ + public function scopeWhereDueStatus($query, $status) + { + return $query->whereIn($this->qualifyColumn('paid_status'), [ + self::STATUS_UNPAID, + self::STATUS_PARTIALLY_PAID, + ]); + } + + /** + * Partial match on the document number. + */ + public function scopeWhereInvoiceNumber($query, $invoiceNumber) + { + return $query->where($this->qualifyColumn('invoice_number'), 'LIKE', '%'.$invoiceNumber.'%'); + } + + /** + * Restrict to documents issued inside the inclusive range. + */ + public function scopeInvoicesBetween($query, $start, $end) + { + return $query->whereBetween($this->qualifyColumn('invoice_date'), [ + $start->format('Y-m-d'), + $end->format('Y-m-d'), + ]); + } + + /** + * Keep only documents whose contact matches every whitespace-separated + * term, a term counting as matched when it turns up in the display name, + * the contact person or the company name. + */ + public function scopeWhereSearch($query, $search) + { + $terms = explode(' ', $search); + + foreach ($terms as $term) { + $query->whereHas('customer', function ($contact) use ($term) { + $needle = '%'.$term.'%'; + + $contact->where('name', 'LIKE', $needle) + ->orWhere('contact_name', 'LIKE', $needle) + ->orWhere('company_name', 'LIKE', $needle); + }); + } + } + + /** + * Sort by a caller-supplied column, sanitised before it reaches SQL. + */ + public function scopeWhereOrder($query, $orderByField, $orderBy) + { + SafeOrderBy::apply($query, $orderByField, $orderBy); + } + + /** + * Run every listed filter that carries a value. + * + * Falsy entries are dropped up front, so a filter sent as an empty string, + * a zero or a null is the same as one that was never sent at all. Order is + * load-bearing: the clauses land in the query in the order written here, + * and the document-id filter contributes an OR, which makes everything + * queued before it part of that alternative. + */ + public function scopeApplyFilters($query, array $filters) + { + $filters = array_filter($filters); + + $clauses = [ + 'search' => fn ($value) => $query->whereSearch($value), + 'status' => fn ($value) => match ($value) { + self::STATUS_UNPAID, self::STATUS_PARTIALLY_PAID, self::STATUS_PAID => $query->wherePaidStatus($value), + 'DUE' => $query->whereDueStatus($value), + default => $query->whereStatus($value), + }, + 'paid_status' => fn ($value) => $query->wherePaidStatus($value), + 'invoice_id' => fn ($value) => $query->whereInvoice($value), + 'invoice_number' => fn ($value) => $query->whereInvoiceNumber($value), + ]; + + foreach ($clauses as $filter => $clause) { + $value = $filters[$filter] ?? null; + + if ($value) { + $clause($value); + } + } + + $from = $filters['from_date'] ?? null; + $to = $filters['to_date'] ?? null; + + if ($from && $to) { + $query->invoicesBetween(Carbon::parse($from), Carbon::parse($to)); + } + + $contact = $filters['customer_id'] ?? null; + + if ($contact) { + $query->where('customer_id', $contact); + } + + $sortField = $filters['orderByField'] ?? null; + + if (! $sortField) { + return $query->orderBy('sequence_number', 'desc'); + } + + return SafeOrderBy::apply($query, $sortField, $filters['orderBy'] ?? 'desc'); + } + + /** + * Widen a listing to also take in one specific document. + */ + public function scopeWhereInvoice($query, $invoice_id) + { + $query->orWhere('id', $invoice_id); + } + + /** + * Narrow to the company the current request is acting on. + */ + public function scopeWhereCompany($query) + { + $query->where($this->qualifyColumn('company_id'), request()->header('company')); + } + + /** + * Narrow to one company. + */ + public function scopeWhereCompanyId($query, $company) + { + $query->where($this->qualifyColumn('company_id'), $company); + } + + /** + * Narrow to one contact. + */ + public function scopeWhereCustomer($query, $customer_id) + { + $query->where($this->qualifyColumn('customer_id'), $customer_id); + } + + /** + * Return the whole result set for the sentinel limit "all", otherwise a + * page of the requested size. + */ + public function scopePaginateData($query, $limit) + { + if ($limit == 'all') { + return $query->get(); + } + + return $query->paginate($limit); + } + + /* + |-------------------------------------------------------------------------- + | Rendering and correspondence + |-------------------------------------------------------------------------- + */ + + /** + * The estimate template matching this document's invoice template, falling + * back to the first estimate template when there is no counterpart. + */ + public function getEstimateTemplateName(): string + { + $counterpart = Str::replace('invoice', 'estimate', $this->template_name); + + // The blank image format is what keeps this cheap: asked for the + // default one, the lister renders a base64 thumbnail of every single + // template just to hand back a list of names. + $available = array_column(PdfTemplateUtils::getFormattedTemplates('estimate', ''), 'name'); + + return in_array($counterpart, $available) ? $counterpart : 'estimate1'; + } + + /** + * View data for the PDF renderer. + */ + public function getPDFData(): mixed + { + return app(InvoicePdfDataProvider::class)->getPdfData($this); + } + + /** + * Whether outgoing mail should carry the PDF. Anything other than an + * explicit refusal counts as consent. + */ + public function getEmailAttachmentSetting(): bool + { + return CompanySetting::getSetting('invoice_email_attachment', $this->company_id) != 'NO'; + } + + /** + * The company's address block for print, or false when the company has no + * address on file. + */ + public function getCompanyAddress(): string|false + { + return $this->addressBlock( + $this->company && (! $this->company->address()->exists()), + 'invoice_company_address_format' + ); + } + + /** + * The contact's delivery address block for print, or false when the + * contact has no shipping address on file. + */ + public function getCustomerShippingAddress(): string|false + { + return $this->addressBlock( + $this->customer && (! $this->customer->shippingAddress()->exists()), + 'invoice_shipping_address_format' + ); + } + + /** + * The contact's billing address block for print, or false when the contact + * has no billing address on file. + */ + public function getCustomerBillingAddress(): string|false + { + return $this->addressBlock( + $this->customer && (! $this->customer->billingAddress()->exists()), + 'invoice_billing_address_format' + ); + } + + /** + * The note field with its placeholders resolved and its markup sanitised. + */ + public function getNotes(): string + { + return PdfHtmlSanitizer::sanitize($this->getFormattedString($this->notes)); + } + + /** + * Resolve the placeholders in a mail body, dropping any that named + * something this document cannot supply. + */ + public function getEmailString(string $body): string + { + $placeholders = array_merge($this->getFieldsArray(), $this->getExtraFields()); + + return preg_replace('/{(.*?)}/', '', strtr($body, $placeholders)); + } + + /** + * The placeholders this document contributes on top of the shared contact + * and company set. + */ + public function getExtraFields(): array + { + return [ + '{INVOICE_DATE}' => $this->formattedInvoiceDate, + '{INVOICE_DUE_DATE}' => $this->formattedDueDate, + '{INVOICE_NUMBER}' => $this->invoice_number, + '{INVOICE_REF_NUMBER}' => $this->reference_number, + ]; + } + + /* + |-------------------------------------------------------------------------- + | Balance and status + |-------------------------------------------------------------------------- + */ + + /** + * Grow the outstanding balance, restate it in the company's currency and + * re-derive both statuses from where it lands. + * + * Growing the balance is what unwinding a collection looks like from the + * document's side, which is why the amount is added rather than taken off. + */ + public function addInvoicePayment(int $amount): void + { + $this->restateBalance($this->due_amount + $amount); + } + + /** + * Shrink the outstanding balance by a collected amount, restating it and + * re-deriving both statuses the same way. + */ + public function subtractInvoicePayment(int $amount): void + { + $this->restateBalance($this->due_amount - $amount); + } + + /** + * Work out the pair of statuses that describes a given outstanding balance. + * + * Nothing outstanding closes the document and clears the overdue flag; a + * balance still standing at the full document total means not a penny has + * arrived; anything in between is a part payment. A negative balance is + * refused outright, and the empty array says so. + */ + public function getInvoiceStatusByAmount(int $amount): array + { + if ($amount < 0) { + return []; + } + + if ($amount == 0) { + return [ + 'status' => self::STATUS_COMPLETED, + 'paid_status' => self::STATUS_PAID, + 'overdue' => false, + ]; + } + + return [ + 'status' => $this->getPreviousStatus(), + 'paid_status' => $amount == $this->total + ? self::STATUS_UNPAID + : self::STATUS_PARTIALLY_PAID, + ]; + } + + /** + * Apply the statuses a given outstanding balance implies and write the row + * back straight away. A balance the derivation refuses leaves the document + * untouched. + */ + public function changeInvoiceStatus(int $amount): void + { + $changes = $this->getInvoiceStatusByAmount($amount); + + if (empty($changes)) { + return; + } + + foreach ($changes as $attribute => $value) { + $this->setAttribute($attribute, $value); + } + + $this->save(); + } + + /* + |-------------------------------------------------------------------------- + | Internals + |-------------------------------------------------------------------------- + */ + + /** + * Whether any credit note reverses this invoice, answered from the loaded + * relation when there is one so that an eager-loaded listing does not fire + * a query per row. + */ + private function hasCreditNotes(): bool + { + if ($this->relationLoaded('creditNotes')) { + return $this->creditNotes->isNotEmpty(); + } + + return $this->creditNotes()->exists(); + } + + /** + * Render one of the company's stored address formats, or hand back false + * when the party it describes is present but has no address on file. + */ + private function addressBlock(bool $missing, string $setting): string|false + { + if ($missing) { + return false; + } + + return $this->getFormattedString(CompanySetting::getSetting($setting, $this->company_id)); + } + + /** + * Move the outstanding balance to a new figure, carry the company-currency + * copy along with it, and let the statuses follow. + */ + private function restateBalance(int|float $outstanding): void + { + $this->due_amount = $outstanding; + $this->base_due_amount = $outstanding * $this->exchange_rate; + + $this->changeInvoiceStatus($outstanding); + } + + /** + * The date format configured by the company that owns this document. + */ + private function documentDateFormat(): mixed + { + return CompanySetting::getSetting('carbon_date_format', $this->company_id); + } +} diff --git a/app/Domains/Sales/Models/InvoiceItem.php b/app/Domains/Sales/Models/InvoiceItem.php new file mode 100644 index 00000000..6ed18614 --- /dev/null +++ b/app/Domains/Sales/Models/InvoiceItem.php @@ -0,0 +1,159 @@ + 'integer', + 'total' => 'integer', + 'discount' => 'float', + 'quantity' => 'float', + 'discount_val' => 'integer', + 'tax' => 'integer', + ]; + } + + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ + + /** + * Document this line was billed on. + */ + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + /** + * Catalog entry the line was copied from, kept for reporting. + */ + public function item(): BelongsTo + { + return $this->belongsTo(Item::class); + } + + /** + * Taxes applied to this line, in per-item tax mode. + */ + public function taxes(): HasMany + { + return $this->hasMany(Tax::class); + } + + /** + * Schedule this line belongs to, when it is part of a recurring template + * rather than of an issued document. + */ + public function recurringInvoice(): BelongsTo + { + return $this->belongsTo(RecurringInvoice::class); + } + + /* + |-------------------------------------------------------------------------- + | Query scopes + |-------------------------------------------------------------------------- + */ + + /** + * Narrow to one company. + */ + public function scopeWhereCompany(Builder $query, int $company_id): void + { + $query->where('company_id', $company_id); + } + + /** + * Restrict to lines billed on a document issued inside the inclusive range. + */ + public function scopeInvoicesBetween(Builder $query, Carbon $start, Carbon $end): void + { + $range = [$start->format('Y-m-d'), $end->format('Y-m-d')]; + + $query->whereHas('invoice', function ($invoice) use ($range) { + $invoice->whereBetween('invoice_date', $range); + }); + } + + /** + * Apply the date range, which counts only when the caller supplied both + * ends of it. + */ + public function scopeApplyInvoiceFilters(Builder $query, array $filters): void + { + $from = $filters['from_date'] ?? null; + $to = $filters['to_date'] ?? null; + + if ($from && $to) { + $query->invoicesBetween( + Carbon::createFromFormat('Y-m-d', $from), + Carbon::createFromFormat('Y-m-d', $to) + ); + } + } + + /** + * Roll the lines up by product name, totalling quantity sold and the + * revenue it brought in, in the company's own currency. + */ + public function scopeItemAttributes(Builder $query): void + { + $columns = [ + 'sum(quantity) as total_quantity', + 'sum(base_total) as total_amount', + 'invoice_items.name', + ]; + + $query->select(DB::raw(implode(', ', $columns))) + ->groupBy('invoice_items.name'); + } +} diff --git a/app/Domains/Sales/Models/RecurringInvoice.php b/app/Domains/Sales/Models/RecurringInvoice.php new file mode 100644 index 00000000..b6ffaf49 --- /dev/null +++ b/app/Domains/Sales/Models/RecurringInvoice.php @@ -0,0 +1,336 @@ + 'float', + 'send_automatically' => 'boolean', + ]; + } + + /** + * Invoices this schedule has produced so far. + */ + public function invoices(): HasMany + { + return $this->hasMany(Invoice::class, 'recurring_invoice_id'); + } + + /** + * Taxes carried by the template, at document level. + */ + public function taxes(): HasMany + { + return $this->hasMany(Tax::class, 'recurring_invoice_id'); + } + + /** + * Line items the generated invoices are built from. + */ + public function items(): HasMany + { + return $this->hasMany(InvoiceItem::class, 'recurring_invoice_id'); + } + + /** + * Contact every generated invoice is billed to. + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class, 'customer_id'); + } + + /** + * Company the schedule was set up under. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id'); + } + + /** + * Author of the schedule, linked through the creator_id column. + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'creator_id'); + } + + /** + * Currency the template amounts are stated in. + */ + public function currency(): BelongsTo + { + return $this->belongsTo(Currency::class, 'currency_id'); + } + + /** + * Start of the schedule, written in the company's date format and in the + * language the application is running in. + */ + public function getFormattedStartsAtAttribute() + { + return Carbon::parse($this->starts_at)->translatedFormat($this->companyDateFormat()); + } + + /** + * The moment the next invoice is due, written in the company's date format + * and in the language the application is running in. + */ + public function getFormattedNextInvoiceAtAttribute() + { + return Carbon::parse($this->next_invoice_at)->translatedFormat($this->companyDateFormat()); + } + + /** + * End date of a date-limited schedule, written in the company's date + * format. Unlike the two above it is not translated. + */ + public function getFormattedLimitDateAttribute() + { + return Carbon::parse($this->limit_date)->format($this->companyDateFormat()); + } + + /** + * Creation date, written in the company's date format and untranslated. + */ + public function getFormattedCreatedAtAttribute() + { + return Carbon::parse($this->created_at)->format($this->companyDateFormat()); + } + + /** + * Narrow to the company the current request is acting on. + */ + public function scopeWhereCompany($query) + { + $company = request()->header('company'); + + return $query->where($this->qualifyColumn('company_id'), $company); + } + + /** + * Return the whole result set for the sentinel limit "all", otherwise a + * page of the requested size. + */ + public function scopePaginateData($query, $limit) + { + return $limit == 'all' ? $query->get() : $query->paginate($limit); + } + + /** + * Sort by a caller-supplied column, sanitised before it reaches SQL. + */ + public function scopeWhereOrder($query, $orderByField, $orderBy) + { + return SafeOrderBy::apply($query, $orderByField, $orderBy); + } + + /** + * Keep only schedules sitting in one lifecycle state. + */ + public function scopeWhereStatus($query, $status) + { + return $query->where($this->qualifyColumn('status'), $status); + } + + /** + * Keep only the schedules billed to one contact. + */ + public function scopeWhereCustomer($query, $customer_id) + { + return $query->where('customer_id', $customer_id); + } + + /** + * Keep only schedules whose start date falls inside the inclusive range. + */ + public function scopeRecurringInvoicesStartBetween($query, $start, $end) + { + return $query->whereBetween('starts_at', [ + $start->format('Y-m-d'), + $end->format('Y-m-d'), + ]); + } + + /** + * Keep only schedules whose contact matches every whitespace-separated + * term, a term counting as matched when it appears in the contact's name, + * the contact person or the company name. + */ + public function scopeWhereSearch($query, $search) + { + $terms = explode(' ', $search); + + foreach ($terms as $term) { + $query->whereHas('customer', function ($customer) use ($term) { + $needle = '%'.$term.'%'; + + $customer->where('name', 'LIKE', $needle) + ->orWhere('contact_name', 'LIKE', $needle) + ->orWhere('company_name', 'LIKE', $needle); + }); + } + } + + /** + * Run every listed filter that carries a value. + */ + public function scopeApplyFilters($query, array $filters) + { + $status = $filters['status'] ?? null; + $search = $filters['search'] ?? null; + $from = $filters['from_date'] ?? null; + $to = $filters['to_date'] ?? null; + $customer = $filters['customer_id'] ?? null; + + if ($status && $status !== 'ALL') { + $query->whereStatus($status); + } + + if ($search) { + $query->whereSearch($search); + } + + if ($from && $to) { + $query->recurringInvoicesStartBetween( + Carbon::createFromFormat('Y-m-d', $from), + Carbon::createFromFormat('Y-m-d', $to) + ); + } + + if ($customer) { + $query->whereCustomer($customer); + } + + $sortField = $filters['orderByField'] ?? null; + $sortDirection = $filters['orderBy'] ?? null; + + if ($sortField || $sortDirection) { + $query->whereOrder($sortField ?: 'created_at', $sortDirection ?: 'asc'); + } + } + + /** + * Retire the schedule, so that no further invoice is generated from it. + */ + public function markStatusAsCompleted(): void + { + $this->status = static::COMPLETED; + $this->save(); + } + + /** + * The moment a cron expression next fires, counted from the given start + * date rather than from now, in the application's own time zone. + */ + public static function getNextInvoiceDate(string $frequency, string $starts_at): string + { + $schedule = new CronExpression($frequency); + $zone = config('app.timezone', 'UTC'); + + return $schedule->getNextRunDate($starts_at, 0, false, $zone)->format('Y-m-d H:i:s'); + } + + /** + * Recompute and store the date the next invoice falls due. + */ + public function updateNextInvoiceDate(): void + { + $this->next_invoice_at = self::getNextInvoiceDate($this->frequency, $this->starts_at); + $this->save(); + } + + /** + * The date format the owning company writes dates in. + */ + private function companyDateFormat() + { + return CompanySetting::getSetting('carbon_date_format', $this->company_id); + } +} diff --git a/app/Domains/Sales/Policies/EstimatePolicy.php b/app/Domains/Sales/Policies/EstimatePolicy.php new file mode 100644 index 00000000..228c2082 --- /dev/null +++ b/app/Domains/Sales/Policies/EstimatePolicy.php @@ -0,0 +1,98 @@ +sameCompany($user, $estimate); + } + + public function create(User $user): bool + { + return BouncerFacade::can('create-estimate', Estimate::class); + } + + public function update(User $user, Estimate $estimate): bool + { + return BouncerFacade::can('edit-estimate', $estimate) && $this->sameCompany($user, $estimate); + } + + public function delete(User $user, Estimate $estimate): bool + { + return $this->mayRemove($user, $estimate); + } + + /** + * Restoring and erasing answer to the delete ability as well; estimates + * are not soft-deleted, so neither is reachable in practice. + */ + public function restore(User $user, Estimate $estimate): bool + { + return $this->mayRemove($user, $estimate); + } + + public function forceDelete(User $user, Estimate $estimate): bool + { + return $this->mayRemove($user, $estimate); + } + + /** + * Mailing the offer to its customer. Left without a return type, as it has + * always been. + * + * @return mixed + */ + public function send(User $user, Estimate $estimate) + { + return BouncerFacade::can('send-estimate', $estimate) && $this->sameCompany($user, $estimate); + } + + /** + * The bulk-delete gate. It is handed no offer, so only the ability half + * applies and nothing here confines it to one company — the endpoint does + * that itself when it resolves the ids. + * + * @return mixed + */ + public function deleteMultiple(User $user) + { + return BouncerFacade::can('delete-estimate', Estimate::class); + } + + private function mayRemove(User $user, Estimate $estimate): bool + { + return BouncerFacade::can('delete-estimate', $estimate) && $this->sameCompany($user, $estimate); + } + + private function sameCompany(User $user, Estimate $estimate): bool + { + return $user->hasCompany($estimate->company_id); + } +} diff --git a/app/Domains/Sales/Policies/InvoicePolicy.php b/app/Domains/Sales/Policies/InvoicePolicy.php new file mode 100644 index 00000000..cedd3439 --- /dev/null +++ b/app/Domains/Sales/Policies/InvoicePolicy.php @@ -0,0 +1,109 @@ +sameCompany($user, $invoice); + } + + public function create(User $user): bool + { + return BouncerFacade::can('create-invoice', Invoice::class); + } + + /** + * Editing answers to a third half on top of the usual two: the document + * has to still be open to it. + * + * A credit note never is. It is a reversal, immutable once minted, because + * saving it back through the invoice form would recompute its totals + * positive. For everything else the model's own accessor decides, which is + * where the company's retrospective-edits setting is read. + */ + public function update(User $user, Invoice $invoice): bool + { + return ! $invoice->isCreditNote() + && BouncerFacade::can('edit-invoice', $invoice) + && $this->sameCompany($user, $invoice) + && $invoice->allow_edit; + } + + public function delete(User $user, Invoice $invoice): bool + { + return $this->mayRemove($user, $invoice); + } + + /** + * Restoring and erasing answer to the delete ability as well; invoices are + * not soft-deleted, so neither is reachable in practice. + */ + public function restore(User $user, Invoice $invoice): bool + { + return $this->mayRemove($user, $invoice); + } + + public function forceDelete(User $user, Invoice $invoice): bool + { + return $this->mayRemove($user, $invoice); + } + + /** + * Mailing the document to its customer. Left without a return type, as it + * has always been. + * + * @return mixed + */ + public function send(User $user, Invoice $invoice) + { + return BouncerFacade::can('send-invoice', $invoice) && $this->sameCompany($user, $invoice); + } + + /** + * The bulk-delete gate. It is handed no document, so only the ability half + * applies and nothing here confines it to one company — the endpoint does + * that itself when it resolves the ids. + * + * @return mixed + */ + public function deleteMultiple(User $user) + { + return BouncerFacade::can('delete-invoice', Invoice::class); + } + + private function mayRemove(User $user, Invoice $invoice): bool + { + return BouncerFacade::can('delete-invoice', $invoice) && $this->sameCompany($user, $invoice); + } + + private function sameCompany(User $user, Invoice $invoice): bool + { + return $user->hasCompany($invoice->company_id); + } +} diff --git a/app/Domains/Sales/Policies/RecurringInvoicePolicy.php b/app/Domains/Sales/Policies/RecurringInvoicePolicy.php new file mode 100644 index 00000000..ae0ed231 --- /dev/null +++ b/app/Domains/Sales/Policies/RecurringInvoicePolicy.php @@ -0,0 +1,91 @@ +sameCompany($user, $recurringInvoice); + } + + public function create(User $user): bool + { + return BouncerFacade::can('create-recurring-invoice', RecurringInvoice::class); + } + + public function update(User $user, RecurringInvoice $recurringInvoice): bool + { + return BouncerFacade::can('edit-recurring-invoice', $recurringInvoice) + && $this->sameCompany($user, $recurringInvoice); + } + + public function delete(User $user, RecurringInvoice $recurringInvoice): bool + { + return $this->mayRemove($user, $recurringInvoice); + } + + /** + * Restoring and erasing answer to the delete ability as well; templates + * are not soft-deleted, so neither is reachable in practice. + */ + public function restore(User $user, RecurringInvoice $recurringInvoice): bool + { + return $this->mayRemove($user, $recurringInvoice); + } + + public function forceDelete(User $user, RecurringInvoice $recurringInvoice): bool + { + return $this->mayRemove($user, $recurringInvoice); + } + + /** + * The bulk-delete gate. It is handed no template, so only the ability half + * applies and nothing here confines it to one company — the endpoint does + * that itself when it resolves the ids. + * + * @return mixed + */ + public function deleteMultiple(User $user) + { + return BouncerFacade::can('delete-recurring-invoice', RecurringInvoice::class); + } + + private function mayRemove(User $user, RecurringInvoice $recurringInvoice): bool + { + return BouncerFacade::can('delete-recurring-invoice', $recurringInvoice) + && $this->sameCompany($user, $recurringInvoice); + } + + private function sameCompany(User $user, RecurringInvoice $recurringInvoice): bool + { + return $user->hasCompany($recurringInvoice->company_id); + } +}