From b1c40e7566e50a9cb93cd3664cf25fbee27a4cdd Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Fri, 21 Aug 2026 09:55:56 +0200 Subject: [PATCH] feat(purchases): fresh purchases implementation --- .../Company/ExpenseCategoriesController.php | 81 ++++ .../Company/ExpensesController.php | 199 ++++++++ .../CustomerPortal/ExpensesController.php | 92 ++++ .../Http/Requests/DeleteExpensesRequest.php | 28 ++ .../Http/Requests/ExpenseCategoryRequest.php | 34 ++ .../Http/Requests/ExpenseRequest.php | 124 +++++ .../Requests/UploadExpenseReceiptRequest.php | 27 ++ .../ExpenseCategoryResource.php | 46 ++ .../CustomerPortal/ExpenseResource.php | 84 ++++ .../Resources/ExpenseCategoryResource.php | 48 ++ .../Http/Resources/ExpenseResource.php | 99 ++++ app/Domains/Purchases/Models/Expense.php | 459 ++++++++++++++++++ .../Purchases/Models/ExpenseCategory.php | 154 ++++++ .../Policies/ExpenseCategoryPolicy.php | 79 +++ .../Purchases/Policies/ExpensePolicy.php | 84 ++++ 15 files changed, 1638 insertions(+) create mode 100644 app/Domains/Purchases/Http/Controllers/Company/ExpenseCategoriesController.php create mode 100644 app/Domains/Purchases/Http/Controllers/Company/ExpensesController.php create mode 100644 app/Domains/Purchases/Http/Controllers/CustomerPortal/ExpensesController.php create mode 100644 app/Domains/Purchases/Http/Requests/DeleteExpensesRequest.php create mode 100644 app/Domains/Purchases/Http/Requests/ExpenseCategoryRequest.php create mode 100644 app/Domains/Purchases/Http/Requests/ExpenseRequest.php create mode 100644 app/Domains/Purchases/Http/Requests/UploadExpenseReceiptRequest.php create mode 100644 app/Domains/Purchases/Http/Resources/CustomerPortal/ExpenseCategoryResource.php create mode 100644 app/Domains/Purchases/Http/Resources/CustomerPortal/ExpenseResource.php create mode 100644 app/Domains/Purchases/Http/Resources/ExpenseCategoryResource.php create mode 100644 app/Domains/Purchases/Http/Resources/ExpenseResource.php create mode 100644 app/Domains/Purchases/Models/Expense.php create mode 100644 app/Domains/Purchases/Models/ExpenseCategory.php create mode 100644 app/Domains/Purchases/Policies/ExpenseCategoryPolicy.php create mode 100644 app/Domains/Purchases/Policies/ExpensePolicy.php diff --git a/app/Domains/Purchases/Http/Controllers/Company/ExpenseCategoriesController.php b/app/Domains/Purchases/Http/Controllers/Company/ExpenseCategoriesController.php new file mode 100644 index 00000000..703c5ecc --- /dev/null +++ b/app/Domains/Purchases/Http/Controllers/Company/ExpenseCategoriesController.php @@ -0,0 +1,81 @@ +authorize('viewAny', ExpenseCategory::class); + + $filters = $request->all(); + + $categories = ExpenseCategory::applyFilters($filters) + ->whereCompany() + ->latest() + ->paginateData($request->input('limit', 5)); + + return ExpenseCategoryResource::collection($categories); + } + + /** + * Add a category to the active company. + */ + public function store(ExpenseCategoryRequest $request) + { + $this->authorize('create', ExpenseCategory::class); + + $category = ExpenseCategory::create($request->getExpenseCategoryPayload()); + + return new ExpenseCategoryResource($category); + } + + /** + * Return one category. + */ + public function show(ExpenseCategory $category) + { + $this->authorize('view', $category); + + return new ExpenseCategoryResource($category); + } + + /** + * Save the submitted changes on a category. + */ + public function update(ExpenseCategoryRequest $request, ExpenseCategory $category) + { + $this->authorize('update', $category); + + $category->update($request->getExpenseCategoryPayload()); + + return new ExpenseCategoryResource($category); + } + + /** + * Drop a category, unless expenses still point at it. + */ + public function destroy(ExpenseCategory $category) + { + $this->authorize('delete', $category); + + $usage = $category->expenses(); + + if ($usage && $usage->count() > 0) { + return respondJson('expense_attached', 'Expense Attached'); + } + + $category->delete(); + + return response()->json(['success' => true]); + } +} diff --git a/app/Domains/Purchases/Http/Controllers/Company/ExpensesController.php b/app/Domains/Purchases/Http/Controllers/Company/ExpensesController.php new file mode 100644 index 00000000..96d55e42 --- /dev/null +++ b/app/Domains/Purchases/Http/Controllers/Company/ExpensesController.php @@ -0,0 +1,199 @@ +authorize('viewAny', Expense::class); + + $filters = $request->all(); + $columns = ['expenses.*', 'expense_categories.name', 'customers.name as user_name']; + + $expenses = Expense::query() + ->with(['category', 'creator', 'fields']) + ->whereCompany() + ->leftJoin('customers', 'expenses.customer_id', '=', 'customers.id') + ->join('expense_categories', 'expenses.expense_category_id', '=', 'expense_categories.id') + ->applyFilters($filters) + ->select($columns) + ->paginateData($request->input('limit', 10)); + + $total = Expense::whereCompany()->count(); + + return ExpenseResource::collection($expenses)->additional([ + 'meta' => ['expense_total_count' => $total], + ]); + } + + /** + * Record a new expense, with its optional taxes, receipt and custom fields. + */ + public function store(ExpenseRequest $request) + { + $this->authorize('create', Expense::class); + + $expense = $this->expenseService->create( + attributes: $request->getExpensePayload(), + taxes: $request->input('taxes'), + receipt: $this->receipt($request), + customFields: $this->customFields($request), + ); + + return new ExpenseResource($expense); + } + + /** + * Return a single expense together with its applied taxes. + */ + public function show(Expense $expense) + { + $this->authorize('view', $expense); + + $expense->load('taxes.taxType'); + + return new ExpenseResource($expense); + } + + /** + * Apply the submitted changes to an existing expense. + */ + public function update(ExpenseRequest $request, Expense $expense) + { + $this->authorize('update', $expense); + + $expense = $this->expenseService->update( + expense: $expense, + attributes: $request->getExpensePayload(), + taxes: $request->input('taxes'), + receipt: $this->receipt($request), + removeReceipt: (bool) $request->input('is_attachment_receipt_removed'), + customFields: $this->customFields($request), + ); + + return new ExpenseResource($expense); + } + + /** + * Drop every submitted expense that belongs to the active company. + * + * @return JsonResponse + */ + public function delete(DeleteExpensesRequest $request) + { + $this->authorize('delete multiple expenses'); + + $deletable = Expense::whereCompany()->whereIn('id', $request->ids)->pluck('id'); + + Expense::destroy($deletable); + + return response()->json(['success' => true]); + } + + /** + * Stream the stored receipt inline, if the expense has one. + */ + public function showReceipt(Expense $expense) + { + $this->authorize('view', $expense); + + $receipt = $this->expenseReceiptManager->first($expense); + + if (! $receipt) { + return respondJson('receipt_does_not_exist', 'Receipt does not exist.'); + } + + return response()->file($receipt->path); + } + + /** + * Store a base64 encoded receipt sent as a JSON blob. + * + * @return JsonResponse + */ + public function uploadReceipt(UploadExpenseReceiptRequest $request, Expense $expense) + { + $this->authorize('update', $expense); + + $payload = json_decode($request->attachment_receipt); + + if ($payload) { + $this->expenseReceiptManager->attachBase64( + $expense, + $payload->data, + $payload->name, + $request->type === 'edit', + ); + } + + return response()->json(['success' => 'Expense receipts uploaded successfully'], 200); + } + + /** + * Send the stored receipt back as a file download. + */ + public function downloadReceipt(Expense $expense) + { + $this->authorize('view', $expense); + + $receipt = $this->expenseReceiptManager->first($expense); + + if (! $receipt) { + return response()->json(['error' => 'receipt_not_found']); + } + + $download = response()->download($receipt->path, $receipt->fileName); + + if (ob_get_contents()) { + ob_end_clean(); + } + + return $download; + } + + /** @return array|null */ + private function customFields(ExpenseRequest $request): ?array + { + $submitted = $request->input('customFields'); + + if (empty($submitted)) { + return null; + } + + $values = is_string($submitted) ? json_decode($submitted) : $submitted; + + return is_array($values) ? $values : null; + } + + private function receipt(ExpenseRequest $request): ?PendingExpenseReceipt + { + $upload = $request->file('attachment_receipt'); + + if (! $upload) { + return null; + } + + return new PendingExpenseReceipt($upload->getPathname(), $upload->getClientOriginalName()); + } +} diff --git a/app/Domains/Purchases/Http/Controllers/CustomerPortal/ExpensesController.php b/app/Domains/Purchases/Http/Controllers/CustomerPortal/ExpensesController.php new file mode 100644 index 00000000..5e583dc0 --- /dev/null +++ b/app/Domains/Purchases/Http/Controllers/CustomerPortal/ExpensesController.php @@ -0,0 +1,92 @@ +has('limit')) { + $perPage = $request->limit; + } + + $contact = Auth::guard('customer')->id(); + + $narrowing = $request->only([ + 'expense_category_id', + 'from_date', + 'to_date', + 'orderByField', + 'orderBy', + ]); + + // The creator is eager loaded but never published by the portal + // payload, while half of what that payload does publish is left for it + // to fetch a row at a time. + $page = Expense::with(['category', 'creator', 'fields']) + ->whereUser($contact) + ->applyFilters($narrowing) + ->paginateData($perPage); + + // Counted afresh rather than taken off the page, so the tally covers + // everything on file for the contact, filters and paging aside. + // + // Except that it does not: there is no such scope, so the name falls + // through to a dynamic clause on a column named "customer" that no + // version of this table has ever had. SQLite reads the unmatched + // identifier as the string 'customer', compares it to the contact id + // and matches nothing, so the tally is reported as zero; MySQL and + // PostgreSQL reject the column outright and the listing fails. Left + // exactly as it stands. + $recorded = Expense::whereCustomer($contact)->count(); + + return ExpenseResource::collection($page)->additional([ + 'meta' => ['expenseTotalCount' => $recorded], + ]); + } + + /** + * Hand back a single expense, 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(); + + $expense = $company->expenses()->whereUser($contact)->where('id', $id)->first(); + + if ($expense === null) { + return response()->json(['error' => 'expense_not_found'], Response::HTTP_NOT_FOUND); + } + + return ExpenseResource::make($expense); + } +} diff --git a/app/Domains/Purchases/Http/Requests/DeleteExpensesRequest.php b/app/Domains/Purchases/Http/Requests/DeleteExpensesRequest.php new file mode 100644 index 00000000..8e68213a --- /dev/null +++ b/app/Domains/Purchases/Http/Requests/DeleteExpensesRequest.php @@ -0,0 +1,28 @@ + ['required'], + 'ids.*' => ['required', Rule::exists('expenses', 'id')], + ]; + } +} diff --git a/app/Domains/Purchases/Http/Requests/ExpenseCategoryRequest.php b/app/Domains/Purchases/Http/Requests/ExpenseCategoryRequest.php new file mode 100644 index 00000000..c0e3e652 --- /dev/null +++ b/app/Domains/Purchases/Http/Requests/ExpenseCategoryRequest.php @@ -0,0 +1,34 @@ + ['required'], + 'description' => ['nullable'], + ]; + } + + public function getExpenseCategoryPayload() + { + return array_merge($this->validated(), [ + 'company_id' => $this->header('company'), + ]); + } +} diff --git a/app/Domains/Purchases/Http/Requests/ExpenseRequest.php b/app/Domains/Purchases/Http/Requests/ExpenseRequest.php new file mode 100644 index 00000000..8e386e8a --- /dev/null +++ b/app/Domains/Purchases/Http/Requests/ExpenseRequest.php @@ -0,0 +1,124 @@ +input('taxes'); + + if (! is_string($submittedTaxes)) { + return; + } + + $decoded = json_decode($submittedTaxes, true); + + $this->merge([ + 'taxes' => json_last_error() === JSON_ERROR_NONE ? $decoded : null, + ]); + } + + /** + * Gatekeeping happens in the controller, so let every caller through here. + */ + public function authorize(): bool + { + return true; + } + + /** + * Rules for creating or editing an expense. + */ + public function rules(): array + { + $rules = [ + 'expense_date' => ['required'], + 'expense_number' => ['nullable', 'string', 'max:255'], + 'expense_category_id' => ['required'], + 'exchange_rate' => ['nullable'], + 'payment_method_id' => ['nullable'], + 'amount' => ['required', 'integer', 'min:0'], + 'customer_id' => ['nullable'], + 'notes' => ['nullable'], + 'currency_id' => ['required'], + 'attachment_receipt' => [ + 'nullable', + 'file', + 'mimes:jpg,png,pdf,doc,docx,xls,xlsx,ppt,pptx', + 'max:20000', + ], + 'taxes' => ['sometimes', 'array'], + 'taxes.*' => ['required', 'array:tax_type_id,amount'], + 'taxes.*.tax_type_id' => [ + 'required', + 'integer', + 'distinct', + Rule::exists('tax_types', 'id') + ->where('company_id', $this->header('company')) + ->where('type', TaxType::TYPE_GENERAL) + ->where('transaction_type', TaxType::TRANSACTION_TYPE_PURCHASES), + ], + 'taxes.*.amount' => ['required', 'integer', 'min:0'], + ]; + + $homeCurrency = CompanySetting::getSetting('currency', $this->header('company')); + + if ($homeCurrency && $this->currency_id && $homeCurrency !== $this->currency_id) { + $rules['exchange_rate'] = ['required']; + } + + return $rules; + } + + /** + * The tax rows may never add up to more than the expense itself. + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $taxes = $this->input('taxes'); + + if (! is_array($taxes)) { + return; + } + + if ($validator->errors()->has('taxes') || $validator->errors()->has('taxes.*')) { + return; + } + + $taxed = collect($taxes)->sum( + fn (mixed $row): int => is_array($row) ? (int) ($row['amount'] ?? 0) : 0 + ); + + if ($taxed > (int) $this->input('amount')) { + $validator->errors()->add('taxes', 'The total tax amount may not exceed the expense amount.'); + } + }); + } + + public function getExpensePayload() + { + $homeCurrency = CompanySetting::getSetting('currency', $this->header('company')); + $chosenCurrency = $this->currency_id; + $rate = $homeCurrency != $chosenCurrency ? $this->exchange_rate : 1; + + return array_merge(Arr::except($this->validated(), 'taxes'), [ + 'creator_id' => $this->user()->id, + 'company_id' => $this->header('company'), + 'exchange_rate' => $rate, + 'base_amount' => $this->amount * $rate, + 'currency_id' => $chosenCurrency, + ]); + } +} diff --git a/app/Domains/Purchases/Http/Requests/UploadExpenseReceiptRequest.php b/app/Domains/Purchases/Http/Requests/UploadExpenseReceiptRequest.php new file mode 100644 index 00000000..4e849115 --- /dev/null +++ b/app/Domains/Purchases/Http/Requests/UploadExpenseReceiptRequest.php @@ -0,0 +1,27 @@ + ['nullable', new Base64Mime(['gif', 'jpg', 'png'])], + ]; + } +} diff --git a/app/Domains/Purchases/Http/Resources/CustomerPortal/ExpenseCategoryResource.php b/app/Domains/Purchases/Http/Resources/CustomerPortal/ExpenseCategoryResource.php new file mode 100644 index 00000000..8ac6c4d8 --- /dev/null +++ b/app/Domains/Purchases/Http/Resources/CustomerPortal/ExpenseCategoryResource.php @@ -0,0 +1,46 @@ +resource; + + return [ + 'id' => $category->id, + 'name' => $category->name, + 'description' => $category->description, + 'company_id' => $category->company_id, + 'amount' => $category->amount, + 'formatted_created_at' => $category->formattedCreatedAt, + 'company' => $this->when( + $category->company()->exists(), + fn () => new CompanyResource($category->company) + ), + ]; + } +} diff --git a/app/Domains/Purchases/Http/Resources/CustomerPortal/ExpenseResource.php b/app/Domains/Purchases/Http/Resources/CustomerPortal/ExpenseResource.php new file mode 100644 index 00000000..1e8cfe0c --- /dev/null +++ b/app/Domains/Purchases/Http/Resources/CustomerPortal/ExpenseResource.php @@ -0,0 +1,84 @@ +resource; + + return [ + 'id' => $expense->id, + 'expense_date' => $expense->expense_date, + 'expense_number' => $expense->expense_number, + 'amount' => $expense->amount, + 'notes' => $expense->notes, + 'customer_id' => $expense->customer_id, + 'attachment_receipt_url' => $expense->receipt_url, + 'attachment_receipt' => $expense->receipt, + 'attachment_receipt_meta' => $expense->receipt_meta, + 'company_id' => $expense->company_id, + 'expense_category_id' => $expense->expense_category_id, + 'formatted_expense_date' => $expense->formattedExpenseDate, + 'formatted_created_at' => $expense->formattedCreatedAt, + 'exchange_rate' => $expense->exchange_rate, + 'currency_id' => $expense->currency_id, + 'base_amount' => $expense->base_amount, + 'payment_method_id' => $expense->payment_method_id, + 'customer' => $this->when( + $expense->customer()->exists(), + fn () => new CustomerResource($expense->customer) + ), + 'expense_category' => $this->when( + $expense->category()->exists(), + fn () => new ExpenseCategoryResource($expense->category) + ), + 'fields' => $this->when( + $expense->fields()->exists(), + fn () => CustomFieldValueResource::collection($expense->fields) + ), + 'company' => $this->when( + $expense->company()->exists(), + fn () => new CompanyResource($expense->company) + ), + 'currency' => $this->when( + $expense->currency()->exists(), + fn () => new CurrencyResource($expense->currency) + ), + 'payment_method' => $this->when( + $expense->paymentMethod()->exists(), + fn () => new PaymentMethodResource($expense->paymentMethod) + ), + ]; + } +} diff --git a/app/Domains/Purchases/Http/Resources/ExpenseCategoryResource.php b/app/Domains/Purchases/Http/Resources/ExpenseCategoryResource.php new file mode 100644 index 00000000..89dac62f --- /dev/null +++ b/app/Domains/Purchases/Http/Resources/ExpenseCategoryResource.php @@ -0,0 +1,48 @@ +resource; + + return [ + 'id' => $category->id, + 'name' => $category->name, + 'description' => $category->description, + 'company_id' => $category->company_id, + 'amount' => $category->amount, + 'formatted_created_at' => $category->formattedCreatedAt, + 'company' => $this->when( + $category->company()->exists(), + fn () => new CompanyResource($category->company) + ), + ]; + } +} diff --git a/app/Domains/Purchases/Http/Resources/ExpenseResource.php b/app/Domains/Purchases/Http/Resources/ExpenseResource.php new file mode 100644 index 00000000..9b549272 --- /dev/null +++ b/app/Domains/Purchases/Http/Resources/ExpenseResource.php @@ -0,0 +1,99 @@ +resource; + + return [ + 'id' => $expense->id, + 'expense_date' => $expense->expense_date, + 'expense_number' => $expense->expense_number, + 'amount' => $expense->amount, + 'notes' => $expense->notes, + 'customer_id' => $expense->customer_id, + 'attachment_receipt_url' => $expense->receipt_url, + 'attachment_receipt' => $expense->receipt, + 'attachment_receipt_meta' => $expense->receipt_meta, + 'company_id' => $expense->company_id, + 'expense_category_id' => $expense->expense_category_id, + 'creator_id' => $expense->creator_id, + 'formatted_expense_date' => $expense->formattedExpenseDate, + 'formatted_created_at' => $expense->formattedCreatedAt, + 'exchange_rate' => $expense->exchange_rate, + 'currency_id' => $expense->currency_id, + 'base_amount' => $expense->base_amount, + 'payment_method_id' => $expense->payment_method_id, + 'taxes' => TaxResource::collection($this->whenLoaded('taxes')), + 'customer' => $this->when( + $expense->customer()->exists(), + fn () => new CustomerResource($expense->customer) + ), + 'expense_category' => $this->when( + $expense->category()->exists(), + fn () => new ExpenseCategoryResource($expense->category) + ), + 'creator' => $this->when( + $expense->creator()->exists(), + fn () => new UserResource($expense->creator) + ), + 'fields' => $this->when( + $expense->fields()->exists(), + fn () => CustomFieldValueResource::collection($expense->fields) + ), + 'company' => $this->when( + $expense->company()->exists(), + fn () => new CompanyResource($expense->company) + ), + 'currency' => $this->when( + $expense->currency()->exists(), + fn () => new CurrencyResource($expense->currency) + ), + 'payment_method' => $this->when( + $expense->paymentMethod()->exists(), + fn () => new PaymentMethodResource($expense->paymentMethod) + ), + ]; + } +} diff --git a/app/Domains/Purchases/Models/Expense.php b/app/Domains/Purchases/Models/Expense.php new file mode 100644 index 00000000..93bd5670 --- /dev/null +++ b/app/Domains/Purchases/Models/Expense.php @@ -0,0 +1,459 @@ + 'string', + 'exchange_rate' => 'float', + ]; + } + + /** + * The one media collection an expense owns. + */ + public function registerMediaCollections(): void + { + $this->addMediaCollection('receipts'); + } + + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ + + /** + * Heading the spend was filed under. + */ + public function category(): BelongsTo + { + return $this->belongsTo(ExpenseCategory::class, 'expense_category_id'); + } + + /** + * Contact the spend was made on behalf of, when one was named. + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class, 'customer_id'); + } + + /** + * Company the expense was booked under. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id'); + } + + /** + * How the money left, when it was recorded. + */ + public function paymentMethod(): BelongsTo + { + return $this->belongsTo(PaymentMethod::class, 'payment_method_id'); + } + + /** + * Currency the expense was entered in -- the client's choice, kept as sent. + */ + public function currency(): BelongsTo + { + return $this->belongsTo(Currency::class, 'currency_id'); + } + + /** + * Staff account that recorded the expense. + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'creator_id'); + } + + /** + * Purchase taxes raised against this expense, each a snapshot of the type + * it came from. + */ + public function taxes(): HasMany + { + return $this->hasMany(Tax::class, 'expense_id'); + } + + /* + |-------------------------------------------------------------------------- + | Accessors + |-------------------------------------------------------------------------- + */ + + /** + * Date of the spend in the company's configured date format, written in + * the language the application is running in. + */ + public function getFormattedExpenseDateAttribute(mixed $value): string + { + $moment = Carbon::parse($this->expense_date); + + return $moment->translatedFormat($this->companyDateFormat()); + } + + /** + * Creation timestamp in the company's configured date format, written in + * the language the application is running in. + */ + public function getFormattedCreatedAtAttribute(mixed $value): string + { + $moment = Carbon::parse($this->created_at); + + return $moment->translatedFormat($this->companyDateFormat()); + } + + /** + * Where the receipt can be fetched from and what kind of file it is, or + * null when nothing is attached. + * + * The link is a relative path rather than a full URL, and it is the + * expense id -- not the media id -- that identifies the file, which is why + * a replacement is reachable at the same address. + */ + public function getReceiptUrlAttribute(mixed $value): ?array + { + $receipt = $this->receiptMedia(); + + if (! $receipt) { + return null; + } + + return [ + 'url' => '/reports/expenses/'.$this->id.'/receipt', + 'type' => $receipt->type, + ]; + } + + /** + * Absolute path of the attached receipt on whichever disk holds it, or + * null when nothing is attached. + */ + public function getReceiptAttribute(mixed $value): ?string + { + $receipt = $this->receiptMedia(); + + if (! $receipt) { + return null; + } + + return $receipt->getPath(); + } + + /** + * The receipt's media record itself, serialized alongside the expense so a + * client can read the file name, size and mime type without a second call. + */ + public function getReceiptMetaAttribute(mixed $value): ?Media + { + return $this->receiptMedia(); + } + + /* + |-------------------------------------------------------------------------- + | Query scopes + |-------------------------------------------------------------------------- + */ + + /** + * Restrict to expenses dated inside the inclusive range. + */ + public function scopeExpensesBetween(Builder $query, Carbon $start, Carbon $end): Builder + { + return $query->whereBetween($this->qualifyColumn('expense_date'), [ + $start->format('Y-m-d'), + $end->format('Y-m-d'), + ]); + } + + /** + * Keep only expenses whose category name contains every + * whitespace-separated term. + */ + public function scopeWhereCategoryName(Builder $query, string $search): void + { + $terms = explode(' ', $search); + + foreach ($terms as $term) { + $needle = self::wildcard($term); + + $query->whereHas('category', fn ($heading) => $heading->where('name', 'LIKE', $needle)); + } + } + + /** + * Partial match on the free-text note. + */ + public function scopeWhereNotes(Builder $query, string $search): void + { + $query->where('notes', 'LIKE', self::wildcard($search)); + } + + /** + * Narrow to one heading. + */ + public function scopeWhereCategory(Builder $query, int $categoryId): Builder + { + return $query->where($this->qualifyColumn('expense_category_id'), $categoryId); + } + + /** + * Narrow to one contact. + */ + public function scopeWhereUser(Builder $query, int $customer_id): Builder + { + return $query->where($this->qualifyColumn('customer_id'), $customer_id); + } + + /** + * Run every listed filter that carries a value. + * + * Order is load-bearing: the clauses land in the query in the order + * written here, and both the expense-id filter and the search contribute + * an OR, which makes everything queued before them part of that + * alternative. A filter sent as an empty string, a zero or a null counts + * as not sent at all, and a half-open date range narrows nothing. + */ + public function scopeApplyFilters(Builder $query, array $filters): void + { + $clauses = [ + 'expense_category_id' => fn ($wanted) => $query->whereCategory($wanted), + 'customer_id' => fn ($wanted) => $query->whereUser($wanted), + 'expense_id' => fn ($wanted) => $query->whereExpense($wanted), + ]; + + foreach ($clauses as $filter => $clause) { + $wanted = $filters[$filter] ?? null; + + if ($wanted) { + $clause($wanted); + } + } + + $from = $filters['from_date'] ?? null; + $to = $filters['to_date'] ?? null; + + if ($from && $to) { + $query->expensesBetween( + Carbon::createFromFormat('Y-m-d', $from), + Carbon::createFromFormat('Y-m-d', $to) + ); + } + + $sortField = $filters['orderByField'] ?? null; + $sortDirection = $filters['orderBy'] ?? null; + + if ($sortField || $sortDirection) { + $query->whereOrder($sortField ?: 'expense_date', $sortDirection ?: 'asc'); + } + + $search = $filters['search'] ?? null; + + if ($search) { + $query->whereSearch($search); + } + } + + /** + * Widen a listing to also take in one specific expense. + * + * The column is deliberately left unqualified: the listing query joins the + * contacts and headings tables, so this is the clause that decides whether + * an id filter is answerable at all. + */ + public function scopeWhereExpense(Builder $query, int $expense_id): void + { + $query->orWhere('id', $expense_id); + } + + /** + * Free-text search over the note and the heading name. + * + * Each whitespace-separated term contributes two alternatives that are + * added to the query side by side rather than grouped, so a multi-term + * search reads as "heading OR note AND heading OR note" and widens the + * result set instead of narrowing it. That is what the listing has always + * done and what its callers expect. + */ + public function scopeWhereSearch(Builder $query, string $search): void + { + $terms = explode(' ', $search); + + foreach ($terms as $term) { + $needle = self::wildcard($term); + + $query->whereHas('category', fn ($heading) => $heading->where('name', 'LIKE', $needle)) + ->orWhere('notes', 'LIKE', $needle); + } + } + + /** + * Sort by a caller-supplied column, sanitised before it reaches SQL and + * falling back to the creation timestamp. + */ + public function scopeWhereOrder(Builder $query, string $orderByField, string $orderBy): void + { + SafeOrderBy::apply($query, $orderByField, $orderBy); + } + + /** + * Narrow to the company the current request is acting on. + */ + public function scopeWhereCompany(Builder $query): void + { + $company = request()->header('company'); + + $query->where($this->qualifyColumn('company_id'), $company); + } + + /** + * Narrow to one named company, for callers that have no request to read. + */ + public function scopeWhereCompanyId(Builder $query, int $company): void + { + $query->where($this->qualifyColumn('company_id'), $company); + } + + /** + * Return the whole result set for the sentinel limit "all", otherwise a + * page of the requested size. + * + * @return Collection|LengthAwarePaginator + */ + public function scopePaginateData(Builder $query, string $limit) + { + return $limit == 'all' ? $query->get() : $query->paginate($limit); + } + + /** + * Collapse the result set to one row per heading, carrying the row count + * and the summed base amount. The select list is replaced outright, so a + * query using this scope hands back the aggregate columns and nothing else. + */ + public function scopeExpensesAttributes(Builder $query): void + { + $query->select(DB::raw('count(*) as expenses_count, sum(base_amount) as total_amount, expense_category_id')) + ->groupBy('expense_category_id'); + } + + /* + |-------------------------------------------------------------------------- + | Internals + |-------------------------------------------------------------------------- + */ + + /** + * The attached receipt, or null when the expense carries none. Only the + * first file in the collection counts -- an expense has one receipt. + */ + private function receiptMedia(): ?Media + { + return $this->getFirstMedia('receipts'); + } + + /** + * The date format this company writes its records in. + */ + private function companyDateFormat(): mixed + { + return CompanySetting::getSetting('carbon_date_format', $this->company_id); + } + + /** + * A term wrapped for a LIKE comparison. + */ + private static function wildcard(string $term): string + { + return '%'.$term.'%'; + } +} diff --git a/app/Domains/Purchases/Models/ExpenseCategory.php b/app/Domains/Purchases/Models/ExpenseCategory.php new file mode 100644 index 00000000..5e941464 --- /dev/null +++ b/app/Domains/Purchases/Models/ExpenseCategory.php @@ -0,0 +1,154 @@ +hasMany(Expense::class, 'expense_category_id'); + } + + /** + * Company the heading belongs to. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id'); + } + + /** + * Creation date in the company's configured date format. + * + * Unlike the expense's own dates this one is not translated -- month and + * day names come out in English whatever language the application is + * running in. + */ + public function getFormattedCreatedAtAttribute(mixed $value): string + { + $format = CompanySetting::getSetting('carbon_date_format', $this->company_id); + + return Carbon::parse($this->created_at)->format($format); + } + + /** + * Everything spent under this heading, summed on the spot. + * + * The figure is the raw `amount` column, so an expense entered in a + * foreign currency contributes its face value rather than its worth in the + * company's books, and headings mixing currencies add up to a number that + * is in no currency at all. + */ + public function getAmountAttribute(): float + { + return $this->expenses()->sum('amount'); + } + + /** + * Narrow to the company the current request is acting on. + * + * The company is read from the request header and never from an argument, + * so a caller passing one is quietly ignored. + */ + public function scopeWhereCompany(Builder $query): void + { + $company = request()->header('company'); + + $query->where('company_id', $company); + } + + /** + * Widen a listing to also take in one specific heading. + */ + public function scopeWhereCategory(Builder $query, int $category_id): void + { + $query->orWhere('id', $category_id); + } + + /** + * Partial match on the heading name. + */ + public function scopeWhereSearch(Builder $query, string $search): void + { + $needle = '%'.$search.'%'; + + $query->where('name', 'LIKE', $needle); + } + + /** + * Run every listed filter that carries a value. + * + * Order is load-bearing: the heading filter contributes an OR, which makes + * whatever follows it part of that alternative. A filter sent as an empty + * string, a zero or a null counts as not sent at all. Note that the + * company filter only decides *whether* to scope by company -- which + * company is taken from the request header, not from the value sent. + */ + public function scopeApplyFilters(Builder $query, array $filters): void + { + $clauses = [ + 'category_id' => fn ($wanted) => $query->whereCategory($wanted), + 'company_id' => fn ($wanted) => $query->whereCompany($wanted), + 'search' => fn ($wanted) => $query->whereSearch($wanted), + ]; + + foreach ($clauses as $filter => $clause) { + $wanted = $filters[$filter] ?? null; + + if ($wanted) { + $clause($wanted); + } + } + } + + /** + * Return the whole result set for the sentinel limit "all", otherwise a + * page of the requested size. + * + * @return Collection|LengthAwarePaginator + */ + public function scopePaginateData(Builder $query, string $limit) + { + return $limit == 'all' ? $query->get() : $query->paginate($limit); + } +} diff --git a/app/Domains/Purchases/Policies/ExpenseCategoryPolicy.php b/app/Domains/Purchases/Policies/ExpenseCategoryPolicy.php new file mode 100644 index 00000000..ede0c7a6 --- /dev/null +++ b/app/Domains/Purchases/Policies/ExpenseCategoryPolicy.php @@ -0,0 +1,79 @@ +mayRead(); + } + + public function view(User $user, ExpenseCategory $expenseCategory): bool + { + return $this->mayReach($user, $expenseCategory); + } + + public function create(User $user): bool + { + return $this->mayRead(); + } + + public function update(User $user, ExpenseCategory $expenseCategory): bool + { + return $this->mayReach($user, $expenseCategory); + } + + public function delete(User $user, ExpenseCategory $expenseCategory): bool + { + return $this->mayReach($user, $expenseCategory); + } + + /** + * Restoring and erasing sit on the same test as deleting; headings are not + * soft-deleted, so neither is reachable in practice. + */ + public function restore(User $user, ExpenseCategory $expenseCategory): bool + { + return $this->mayReach($user, $expenseCategory); + } + + public function forceDelete(User $user, ExpenseCategory $expenseCategory): bool + { + return $this->mayReach($user, $expenseCategory); + } + + /** + * The one ability every decision here rests on, asked of the expense class + * rather than of any heading. + */ + private function mayRead(): bool + { + return BouncerFacade::can('view-expense', Expense::class); + } + + private function mayReach(User $user, ExpenseCategory $expenseCategory): bool + { + return $this->mayRead() && $user->hasCompany($expenseCategory->company_id); + } +} diff --git a/app/Domains/Purchases/Policies/ExpensePolicy.php b/app/Domains/Purchases/Policies/ExpensePolicy.php new file mode 100644 index 00000000..e9bbba04 --- /dev/null +++ b/app/Domains/Purchases/Policies/ExpensePolicy.php @@ -0,0 +1,84 @@ +sameCompany($user, $expense); + } + + public function create(User $user): bool + { + return BouncerFacade::can('create-expense', Expense::class); + } + + public function update(User $user, Expense $expense): bool + { + return BouncerFacade::can('edit-expense', $expense) && $this->sameCompany($user, $expense); + } + + public function delete(User $user, Expense $expense): bool + { + return $this->mayRemove($user, $expense); + } + + /** + * Restoring and erasing are governed by the delete ability as well; + * expenses are not soft-deleted, so neither is reachable in practice. + */ + public function restore(User $user, Expense $expense): bool + { + return $this->mayRemove($user, $expense); + } + + public function forceDelete(User $user, Expense $expense): bool + { + return $this->mayRemove($user, $expense); + } + + /** + * Clearing a batch of expenses at once. + * + * There is no row to check membership against here, so the delete ability + * on the class is the whole test; the deletion itself is scoped to the + * acting company by the caller. + */ + public function deleteMultiple(User $user) + { + return BouncerFacade::can('delete-expense', Expense::class); + } + + private function mayRemove(User $user, Expense $expense): bool + { + return BouncerFacade::can('delete-expense', $expense) && $this->sameCompany($user, $expense); + } + + private function sameCompany(User $user, Expense $expense): bool + { + return $user->hasCompany($expense->company_id); + } +}