diff --git a/app/Domains/Money/Http/Controllers/CurrenciesController.php b/app/Domains/Money/Http/Controllers/CurrenciesController.php new file mode 100644 index 00000000..035cd662 --- /dev/null +++ b/app/Domains/Money/Http/Controllers/CurrenciesController.php @@ -0,0 +1,28 @@ +currencyService->getAllWithCommonFirst(), + ); + } +} diff --git a/app/Domains/Money/Http/Controllers/ExchangeRateProviderController.php b/app/Domains/Money/Http/Controllers/ExchangeRateProviderController.php new file mode 100644 index 00000000..711ed04e --- /dev/null +++ b/app/Domains/Money/Http/Controllers/ExchangeRateProviderController.php @@ -0,0 +1,286 @@ +authorize('viewAny', ExchangeRateProvider::class); + + $providers = ExchangeRateProvider::whereCompany()->paginate($request->input('limit', 5)); + + return ExchangeRateProviderResource::collection($providers); + } + + public function store(ExchangeRateProviderRequest $request) + { + $this->authorize('create', ExchangeRateProvider::class); + + $payload = $request->getExchangeRateProviderPayload(); + + $taken = $this->exchangeRateProviderService->checkActiveCurrencies($payload['currencies'] ?? []); + + if ($taken->isNotEmpty()) { + return respondJson('currency_used', 'Currency used.'); + } + + try { + // The credentials are proven against the live service before a row exists. + $this->exchangeRateProviderService->validateProvider($payload); + + return new ExchangeRateProviderResource( + $this->exchangeRateProviderService->create($payload), + ); + } catch (ExchangeRateException $exception) { + return respondJson($exception->errorKey, $exception->getMessage()); + } + } + + public function show(ExchangeRateProvider $exchangeRateProvider) + { + $this->authorize('view', $exchangeRateProvider); + + return new ExchangeRateProviderResource($exchangeRateProvider); + } + + public function update(ExchangeRateProviderRequest $request, ExchangeRateProvider $exchangeRateProvider) + { + $this->authorize('update', $exchangeRateProvider); + + $payload = $request->getExchangeRateProviderPayload(); + + $taken = $this->exchangeRateProviderService->checkUpdateActiveCurrencies( + $exchangeRateProvider, + $payload['currencies'] ?? [], + ); + + if ($taken->isNotEmpty()) { + return respondJson('currency_used', 'Currency used.'); + } + + try { + $this->exchangeRateProviderService->validateProvider($payload); + $this->exchangeRateProviderService->update($exchangeRateProvider, $payload); + + return new ExchangeRateProviderResource($exchangeRateProvider); + } catch (ExchangeRateException $exception) { + return respondJson($exception->errorKey, $exception->getMessage()); + } + } + + public function destroy(ExchangeRateProvider $exchangeRateProvider) + { + $this->authorize('delete', $exchangeRateProvider); + + // Switching a provider off is a separate, deliberate step before removal. + if ($exchangeRateProvider->active == true) { + return respondJson('provider_active', 'Provider Active.'); + } + + $exchangeRateProvider->delete(); + + return response()->json([ + 'success' => true, + ]); + } + + public function usedCurrencies(Request $request) + { + $this->authorize('viewAny', ExchangeRateProvider::class); + + $exclude = $request->input('provider_id'); + + $active = ExchangeRateProvider::where('active', true) + ->whereCompany() + ->when($exclude, fn ($query) => $query->where('id', '<>', $exclude)) + ->pluck('currencies'); + + $all = ExchangeRateProvider::whereCompany()->pluck('currencies'); + + return response()->json([ + 'allUsedCurrencies' => $this->collectCodes($all), + 'activeUsedCurrencies' => $this->collectCodes($active), + ]); + } + + public function supportedCurrencies(Request $request) + { + $this->authorize('viewAny', ExchangeRateProvider::class); + + try { + $currencies = $this->exchangeRateProviderService->getSupportedCurrencies( + $request->input('driver'), + $request->input('key'), + $request->input('driver_config') ?? [], + ); + + return response()->json(['supportedCurrencies' => $currencies]); + } catch (ExchangeRateException $exception) { + return respondJson($exception->errorKey, $exception->getMessage()); + } + } + + /** + * Both outcomes are a 200 — the SPA switches on the body, not the status. + */ + public function activeProvider(Request $request, Currency $currency) + { + $covered = ExchangeRateProvider::whereCompany() + ->where('active', true) + ->whereJsonContains('currencies', $currency->code) + ->exists(); + + if ($covered) { + return response()->json([ + 'success' => true, + 'message' => 'provider_active', + ], 200); + } + + return response()->json([ + 'error' => 'no_active_provider', + ], 200); + } + + /** + * Rate from the given currency to the company's base currency: live when a + * provider covers it, otherwise the newest logged rate, otherwise nothing. + */ + public function getRate(Request $request, Currency $currency) + { + $baseCurrency = $this->companyBaseCurrency($request); + + $live = $this->fetchLiveRate($currency, $baseCurrency); + + if ($live !== null) { + return response()->json(['exchangeRate' => $live]); + } + + // Note the column naming: base_currency_id carries the document + // currency and currency_id the company's base currency. + $logged = ExchangeRateLog::where('base_currency_id', $currency->id) + ->where('currency_id', $baseCurrency->id) + ->latest() + ->value('exchange_rate'); + + if ($logged) { + return response()->json([ + 'exchangeRate' => [$logged], + ], 200); + } + + return response()->json([ + 'error' => 'no_exchange_rate_available', + ], 200); + } + + public function usedCurrenciesWithoutRate(Request $request) + { + $ids = $this->exchangeRateBackfill->currencyIdsMissingRates(); + + return response()->json([ + 'currencies' => Currency::whereIn('id', $ids)->get(), + ]); + } + + public function bulkUpdate(BulkExchangeRateRequest $request) + { + $applied = $this->exchangeRateBackfill->apply( + (int) $request->header('company'), + $request->validated('currencies'), + ); + + if ($applied) { + return response()->json([ + 'success' => true, + ]); + } + + // The backfill has already run for this company; nothing was touched. + return response()->json([ + 'error' => false, + ]); + } + + /** + * Currency codes of every provider in the given set, one entry per provider + * that covers a code — repeats are meaningful and stay in. + * + * @param Collection $providerCurrencies + * @return array + */ + private function collectCodes(Collection $providerCurrencies): array + { + return $providerCurrencies + ->filter(fn ($codes): bool => is_array($codes)) + ->flatten(1) + ->values() + ->all(); + } + + private function companyBaseCurrency(Request $request): Currency + { + $settings = CompanySetting::getSettings(['currency'], $request->header('company')); + + return Currency::findOrFail($settings['currency']); + } + + /** + * Ask the first active provider covering the code — any company's, by + * long-standing design. An unreachable or misconfigured service is not an + * error here; the caller falls back to the rate log. + * + * @return array|null + */ + private function fetchLiveRate(Currency $currency, Currency $baseCurrency): ?array + { + $provider = ExchangeRateProvider::whereJsonContains('currencies', $currency->code) + ->where('active', true) + ->first(); + + if (! $provider) { + return null; + } + + try { + return $this->exchangeRateProviderService->getExchangeRate( + $provider->driver, + $provider->key, + $provider->driver_config ?? [], + $currency->code, + $baseCurrency->code, + ); + } catch (ExchangeRateException) { + return null; + } + } +} diff --git a/app/Domains/Money/Http/Requests/BulkExchangeRateRequest.php b/app/Domains/Money/Http/Requests/BulkExchangeRateRequest.php new file mode 100644 index 00000000..457d9812 --- /dev/null +++ b/app/Domains/Money/Http/Requests/BulkExchangeRateRequest.php @@ -0,0 +1,29 @@ +> + */ + public function rules(): array + { + return [ + 'currencies' => ['required'], + 'currencies.*.id' => ['required', 'numeric'], + 'currencies.*.exchange_rate' => ['required'], + ]; + } +} diff --git a/app/Domains/Money/Http/Requests/ExchangeRateProviderRequest.php b/app/Domains/Money/Http/Requests/ExchangeRateProviderRequest.php new file mode 100644 index 00000000..7bf6c5c3 --- /dev/null +++ b/app/Domains/Money/Http/Requests/ExchangeRateProviderRequest.php @@ -0,0 +1,46 @@ +> + */ + public function rules(): array + { + return [ + 'driver' => ['required'], + 'key' => ['required'], + 'currencies' => ['nullable'], + 'currencies.*' => ['nullable'], + 'driver_config' => ['nullable'], + + // A dedicated CurrencyConverter plan lets the operator name the + // endpoint we then call with their key: keep it off private and + // otherwise non-routable hosts on top of the syntax check. + 'driver_config.url' => ['nullable', 'string', 'url', new PublicHttpUrl], + + 'active' => ['nullable', 'boolean'], + ]; + } + + /** + * Validated attributes with the company taken from the request context — + * a client-supplied company_id is never honoured. + */ + public function getExchangeRateProviderPayload() + { + return collect($this->validated()) + ->merge(['company_id' => $this->header('company')]) + ->toArray(); + } +} diff --git a/app/Domains/Money/Http/Resources/CurrencyResource.php b/app/Domains/Money/Http/Resources/CurrencyResource.php new file mode 100644 index 00000000..e96a7ae1 --- /dev/null +++ b/app/Domains/Money/Http/Resources/CurrencyResource.php @@ -0,0 +1,31 @@ + $this->id, + 'name' => $this->name, + 'code' => $this->code, + 'symbol' => $this->symbol, + 'precision' => $this->precision, + 'thousand_separator' => $this->thousand_separator, + 'decimal_separator' => $this->decimal_separator, + 'swap_currency_symbol' => $this->swap_currency_symbol, + 'exchange_rate' => $this->exchange_rate, + ]; + } +} diff --git a/app/Domains/Money/Http/Resources/CustomerPortal/CurrencyResource.php b/app/Domains/Money/Http/Resources/CustomerPortal/CurrencyResource.php new file mode 100644 index 00000000..f8211353 --- /dev/null +++ b/app/Domains/Money/Http/Resources/CustomerPortal/CurrencyResource.php @@ -0,0 +1,31 @@ + $this->id, + 'name' => $this->name, + 'code' => $this->code, + 'symbol' => $this->symbol, + 'precision' => $this->precision, + 'thousand_separator' => $this->thousand_separator, + 'decimal_separator' => $this->decimal_separator, + 'swap_currency_symbol' => $this->swap_currency_symbol, + 'exchange_rate' => $this->exchange_rate, + ]; + } +} diff --git a/app/Domains/Money/Http/Resources/ExchangeRateProviderResource.php b/app/Domains/Money/Http/Resources/ExchangeRateProviderResource.php new file mode 100644 index 00000000..e98022e8 --- /dev/null +++ b/app/Domains/Money/Http/Resources/ExchangeRateProviderResource.php @@ -0,0 +1,30 @@ + $this->id, + 'key' => $this->key, + 'driver' => $this->driver, + 'currencies' => $this->currencies, + 'driver_config' => $this->driver_config, + 'company_id' => $this->company_id, + 'active' => $this->active, + 'company' => $this->when( + $this->company()->exists(), + fn (): CompanyResource => new CompanyResource($this->company) + ), + ]; + } +} diff --git a/app/Domains/Money/Models/Currency.php b/app/Domains/Money/Models/Currency.php new file mode 100644 index 00000000..6be13012 --- /dev/null +++ b/app/Domains/Money/Models/Currency.php @@ -0,0 +1,31 @@ + 'float', + ]; + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function currency(): BelongsTo + { + return $this->belongsTo(Currency::class); + } + + /** + * Log the rate a priced record (invoice, estimate, expense, payment) was + * captured with. The company side of the pair is read from settings. + */ + public static function addExchangeRateLog(mixed $model): self + { + return static::create([ + 'exchange_rate' => $model->exchange_rate, + 'company_id' => $model->company_id, + 'base_currency_id' => $model->currency_id, + 'currency_id' => CompanySetting::getSetting('currency', $model->company_id), + ]); + } +} diff --git a/app/Domains/Money/Models/ExchangeRateProvider.php b/app/Domains/Money/Models/ExchangeRateProvider.php new file mode 100644 index 00000000..8b200aa6 --- /dev/null +++ b/app/Domains/Money/Models/ExchangeRateProvider.php @@ -0,0 +1,55 @@ + 'array', + 'driver_config' => 'array', + 'active' => 'boolean', + ]; + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function setCurrenciesAttribute($value) + { + $this->attributes['currencies'] = json_encode($value); + } + + public function setDriverConfigAttribute($value) + { + $this->attributes['driver_config'] = json_encode($value); + } + + public function scopeWhereCompany($query) + { + $query->where('exchange_rate_providers.company_id', request()->header('company')); + } +} diff --git a/app/Domains/Money/Policies/ExchangeRateProviderPolicy.php b/app/Domains/Money/Policies/ExchangeRateProviderPolicy.php new file mode 100644 index 00000000..93e0e816 --- /dev/null +++ b/app/Domains/Money/Policies/ExchangeRateProviderPolicy.php @@ -0,0 +1,57 @@ +hasCompany($exchangeRateProvider->company_id); + } + + public function create(User $user): bool + { + return BouncerFacade::can('create-exchange-rate-provider', ExchangeRateProvider::class); + } + + public function update(User $user, ExchangeRateProvider $exchangeRateProvider): bool + { + return BouncerFacade::can('edit-exchange-rate-provider', $exchangeRateProvider) + && $user->hasCompany($exchangeRateProvider->company_id); + } + + public function delete(User $user, ExchangeRateProvider $exchangeRateProvider): bool + { + return BouncerFacade::can('delete-exchange-rate-provider', $exchangeRateProvider) + && $user->hasCompany($exchangeRateProvider->company_id); + } + + // Soft deletes are not wired up for providers; these remain declared but + // intentionally answer nothing, as no route reaches them. + public function restore(User $user, ExchangeRateProvider $exchangeRateProvider): bool + { + // + } + + public function forceDelete(User $user, ExchangeRateProvider $exchangeRateProvider): bool + { + // + } +} diff --git a/app/Domains/Taxation/Http/Controllers/TaxTypesController.php b/app/Domains/Taxation/Http/Controllers/TaxTypesController.php new file mode 100644 index 00000000..2e2d7f93 --- /dev/null +++ b/app/Domains/Taxation/Http/Controllers/TaxTypesController.php @@ -0,0 +1,98 @@ +authorize('viewAny', TaxType::class); + + $perPage = $request->has('limit') ? $request->limit : 5; + + // Clause order matters: the filters go on first, then the kind and + // company narrowing, and `latest()` trails any explicit ordering. + $taxTypes = TaxType::applyFilters($request->all()) + ->where('type', TaxType::TYPE_GENERAL) + ->whereCompany() + ->latest() + ->paginateData($perPage); + + return TaxTypeResource::collection($taxTypes); + } + + /** + * Persist a new tax type. The kind and the company are pinned by the + * request object, whatever the client sent for them. + * + * The resource answers 201 by itself, because the wrapped model was just + * created and the verb is POST. + */ + public function store(TaxTypeRequest $request) + { + $this->authorize('create', TaxType::class); + + $taxType = TaxType::create($request->getTaxTypePayload()); + + return new TaxTypeResource($taxType); + } + + public function show(TaxType $taxType) + { + $this->authorize('view', $taxType); + + return new TaxTypeResource($taxType); + } + + public function update(TaxTypeRequest $request, TaxType $taxType) + { + $this->authorize('update', $taxType); + + $taxType->update($request->getTaxTypePayload()); + + return new TaxTypeResource($taxType); + } + + /** + * Drop a tax type, unless documents already carry a tax taken from it. + * + * Applied taxes are snapshots that keep pointing at their origin, so the + * row has to survive for as long as any of them exist; the refusal is a + * 422 carrying the `taxes_attached` key the SPA switches on. + */ + public function destroy(TaxType $taxType) + { + $this->authorize('delete', $taxType); + + if ($taxType->taxes()->exists()) { + return respondJson('taxes_attached', 'Taxes Attached.'); + } + + $taxType->delete(); + + return response()->json([ + 'success' => true, + ]); + } +} diff --git a/app/Domains/Taxation/Http/Resources/CustomerPortal/TaxResource.php b/app/Domains/Taxation/Http/Resources/CustomerPortal/TaxResource.php new file mode 100644 index 00000000..b2fb09b5 --- /dev/null +++ b/app/Domains/Taxation/Http/Resources/CustomerPortal/TaxResource.php @@ -0,0 +1,50 @@ + $this->id, + 'tax_type_id' => $this->tax_type_id, + 'invoice_id' => $this->invoice_id, + 'estimate_id' => $this->estimate_id, + 'invoice_item_id' => $this->invoice_item_id, + 'estimate_item_id' => $this->estimate_item_id, + 'item_id' => $this->item_id, + 'company_id' => $this->company_id, + 'name' => $this->name, + 'amount' => $this->amount, + 'percent' => $this->percent, + 'compound_tax' => $this->compound_tax, + 'base_amount' => $this->base_amount, + 'currency_id' => $this->currency_id, + 'recurring_invoice_id' => $this->recurring_invoice_id, + 'tax_type' => $this->when( + $this->taxType()->exists(), + fn () => new TaxTypeResource($this->taxType) + ), + 'currency' => $this->when( + $this->currency()->exists(), + fn () => new CurrencyResource($this->currency) + ), + ]; + } +} diff --git a/app/Domains/Taxation/Http/Resources/CustomerPortal/TaxTypeResource.php b/app/Domains/Taxation/Http/Resources/CustomerPortal/TaxTypeResource.php new file mode 100644 index 00000000..d101ac6c --- /dev/null +++ b/app/Domains/Taxation/Http/Resources/CustomerPortal/TaxTypeResource.php @@ -0,0 +1,38 @@ + $this->id, + 'name' => $this->name, + 'percent' => $this->percent, + 'transaction_type' => $this->transaction_type, + 'compound_tax' => $this->compound_tax, + 'collective_tax' => $this->collective_tax, + 'description' => $this->description, + 'company_id' => $this->company_id, + 'company' => $this->when( + $this->company()->exists(), + fn () => new CompanyResource($this->company) + ), + ]; + } +} diff --git a/app/Domains/Taxation/Http/Resources/TaxResource.php b/app/Domains/Taxation/Http/Resources/TaxResource.php new file mode 100644 index 00000000..2af2a6cf --- /dev/null +++ b/app/Domains/Taxation/Http/Resources/TaxResource.php @@ -0,0 +1,57 @@ + $this->id, + 'tax_type_id' => $this->tax_type_id, + 'invoice_id' => $this->invoice_id, + 'estimate_id' => $this->estimate_id, + 'invoice_item_id' => $this->invoice_item_id, + 'estimate_item_id' => $this->estimate_item_id, + 'expense_id' => $this->expense_id, + 'item_id' => $this->item_id, + 'company_id' => $this->company_id, + 'name' => $this->name, + 'amount' => $this->amount, + 'percent' => $this->percent, + 'calculation_type' => $this->calculation_type, + 'fixed_amount' => $this->fixed_amount, + 'compound_tax' => $this->compound_tax, + 'base_amount' => $this->base_amount, + 'currency_id' => $this->currency_id, + // Dereferenced without a guard: a tax type with rows attached + // cannot be deleted, so the parent is always there. + 'type' => $this->taxType->type, + 'recurring_invoice_id' => $this->recurring_invoice_id, + 'tax_type' => $this->when( + $this->taxType()->exists(), + fn () => new TaxTypeResource($this->taxType) + ), + 'currency' => $this->when( + $this->currency()->exists(), + fn () => new CurrencyResource($this->currency) + ), + ]; + } +} diff --git a/app/Domains/Taxation/Http/Resources/TaxTypeResource.php b/app/Domains/Taxation/Http/Resources/TaxTypeResource.php new file mode 100644 index 00000000..fa522003 --- /dev/null +++ b/app/Domains/Taxation/Http/Resources/TaxTypeResource.php @@ -0,0 +1,41 @@ + $this->id, + 'name' => $this->name, + 'percent' => $this->percent, + 'fixed_amount' => $this->fixed_amount, + 'calculation_type' => $this->calculation_type, + 'type' => $this->type, + 'transaction_type' => $this->transaction_type, + 'compound_tax' => $this->compound_tax, + 'collective_tax' => $this->collective_tax, + 'description' => $this->description, + 'company_id' => $this->company_id, + 'company' => $this->when( + $this->company()->exists(), + fn () => new CompanyResource($this->company) + ), + ]; + } +} diff --git a/app/Domains/Taxation/Models/Tax.php b/app/Domains/Taxation/Models/Tax.php new file mode 100644 index 00000000..851018c3 --- /dev/null +++ b/app/Domains/Taxation/Models/Tax.php @@ -0,0 +1,183 @@ + 'integer', + 'percent' => 'float', + 'fixed_amount' => 'integer', + 'compound_tax' => 'boolean', + ]; + } + + /** + * The type this row was snapshotted from. Deleting a referenced type is + * refused, so this reference can be dereferenced without a null check. + */ + public function taxType(): BelongsTo + { + return $this->belongsTo(TaxType::class); + } + + public function currency(): BelongsTo + { + return $this->belongsTo(Currency::class); + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + public function invoiceItem(): BelongsTo + { + return $this->belongsTo(InvoiceItem::class); + } + + public function estimate(): BelongsTo + { + return $this->belongsTo(Estimate::class); + } + + public function estimateItem(): BelongsTo + { + return $this->belongsTo(EstimateItem::class); + } + + public function recurringInvoice(): BelongsTo + { + return $this->belongsTo(RecurringInvoice::class); + } + + public function expense(): BelongsTo + { + return $this->belongsTo(Expense::class); + } + + public function item(): BelongsTo + { + return $this->belongsTo(Item::class); + } + + public function scopeWhereCompany(Builder $query, int $company_id): void + { + $query->where('company_id', $company_id); + } + + /** + * Collapse the result set to one row per tax type, carrying 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 scopeTaxAttributes(Builder $query): void + { + $query->select(DB::raw('sum(base_amount) as total_tax_amount, tax_type_id')) + ->groupBy('tax_type_id'); + } + + /** + * Reporting filter: taxes that belong to paid invoices dated in the range. + * Both ends are required -- a half-open range narrows nothing. + */ + public function scopeWhereInvoicesFilters(Builder $query, array $filters): void + { + if ($range = $this->closedDateRange($filters)) { + $query->invoicesBetween(...$range); + } + } + + /** + * A tax can hang off the invoice itself or off one of its line items, so + * both routes to the parent document are considered; the pair is wrapped + * in its own group to keep the OR from leaking into surrounding clauses. + */ + public function scopeInvoicesBetween(Builder $query, Carbon $start, Carbon $end): void + { + $paidWithin = function (Builder $invoices) use ($start, $end): void { + $invoices->where('paid_status', Invoice::STATUS_PAID) + ->whereBetween('invoice_date', [$start->format('Y-m-d'), $end->format('Y-m-d')]); + }; + + $query->where(function (Builder $taxes) use ($paidWithin): void { + $taxes->whereHas('invoice', $paidWithin) + ->orWhereHas('invoiceItem.invoice', $paidWithin); + }); + } + + /** + * Reporting filter: taxes on expenses dated in the range. Expenses have no + * paid state, so unlike invoices there is no status condition here. + */ + public function scopeWhereExpensesFilters(Builder $query, array $filters): void + { + if ($range = $this->closedDateRange($filters)) { + $query->expensesBetween(...$range); + } + } + + public function scopeExpensesBetween(Builder $query, Carbon $start, Carbon $end): void + { + $query->whereHas('expense', function (Builder $expenses) use ($start, $end): void { + $expenses->whereBetween('expense_date', [$start->format('Y-m-d'), $end->format('Y-m-d')]); + }); + } + + /** + * Turn the `from_date` / `to_date` filter pair into Carbon bounds, or null + * when either end is missing. + * + * @return array{0: Carbon, 1: Carbon}|null + */ + private function closedDateRange(array $filters): ?array + { + $from = $filters['from_date'] ?? null; + $to = $filters['to_date'] ?? null; + + if (! $from || ! $to) { + return null; + } + + return [ + Carbon::createFromFormat('Y-m-d', $from), + Carbon::createFromFormat('Y-m-d', $to), + ]; + } +} diff --git a/app/Domains/Taxation/Models/TaxType.php b/app/Domains/Taxation/Models/TaxType.php new file mode 100644 index 00000000..09098043 --- /dev/null +++ b/app/Domains/Taxation/Models/TaxType.php @@ -0,0 +1,144 @@ + 'float', + 'fixed_amount' => 'integer', + 'compound_tax' => 'boolean', + ]; + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + /** + * Every applied-tax row snapshotted from this type. Non-empty means the + * type is pinned: deletion is refused while any of these exist. + */ + public function taxes(): HasMany + { + return $this->hasMany(Tax::class); + } + + /** + * Translate the listing's query string into query conditions. + * + * The order matters for the id filter: `whereTaxType` contributes an OR + * clause, and it is only harmless because it lands first, where Eloquent + * strips the leading boolean. Everything after it ANDs as expected. + */ + public function scopeApplyFilters(Builder $query, array $filters): void + { + $filters = collect($filters); + + if ($filters->get('tax_type_id')) { + $query->whereTaxType($filters->get('tax_type_id')); + } + + if ($filters->get('company_id')) { + // The submitted id is not honoured -- the scope reads the company + // from the request header. The filter merely switches it on. + $query->whereCompany(); + } + + if ($filters->get('transaction_type')) { + $query->whereTransactionType($filters->get('transaction_type')); + } + + if ($filters->get('search')) { + $query->whereSearch($filters->get('search')); + } + + if ($filters->get('orderByField') || $filters->get('orderBy')) { + // 'payment_number' is not a column on this table: a legacy + // carry-over from the payments listing, kept as-is. + $query->whereOrder( + $filters->get('orderByField') ?: 'payment_number', + $filters->get('orderBy') ?: 'asc' + ); + } + } + + /** + * Scope to the company the request is acting in. Any argument passed by a + * caller is deliberately ignored -- the header is the only source. + */ + public function scopeWhereCompany(Builder $query): void + { + $query->where('company_id', request()->header('company')); + } + + public function scopeWhereTaxType(Builder $query, int $tax_type_id): void + { + $query->orWhere('id', $tax_type_id); + } + + public function scopeWhereTransactionType(Builder $query, string $transaction_type): void + { + $query->where('transaction_type', $transaction_type); + } + + public function scopeWhereSearch(Builder $query, string $search): void + { + $query->where('name', 'LIKE', '%'.$search.'%'); + } + + /** + * User-supplied sort input, sanitised before it reaches the ORDER BY. + */ + public function scopeWhereOrder(Builder $query, string $orderByField, string $orderBy): void + { + SafeOrderBy::apply($query, $orderByField, $orderBy); + } + + /** + * `limit=all` opts out of pagination and returns the plain collection. + * + * @return Collection|LengthAwarePaginator + */ + public function scopePaginateData(Builder $query, string $limit) + { + return $limit === 'all' ? $query->get() : $query->paginate($limit); + } +} diff --git a/app/Domains/Taxation/Policies/TaxTypePolicy.php b/app/Domains/Taxation/Policies/TaxTypePolicy.php new file mode 100644 index 00000000..7c55ce50 --- /dev/null +++ b/app/Domains/Taxation/Policies/TaxTypePolicy.php @@ -0,0 +1,72 @@ +sameCompany($user, $taxType); + } + + public function create(User $user): bool + { + return BouncerFacade::can('create-tax-type', TaxType::class); + } + + public function update(User $user, TaxType $taxType): bool + { + return BouncerFacade::can('edit-tax-type', $taxType) && $this->sameCompany($user, $taxType); + } + + public function delete(User $user, TaxType $taxType): bool + { + return $this->mayRemove($user, $taxType); + } + + /** + * Restoring and erasing are governed by the delete ability as well; tax + * types are not soft-deleted, so neither is reachable in practice. + */ + public function restore(User $user, TaxType $taxType): bool + { + return $this->mayRemove($user, $taxType); + } + + public function forceDelete(User $user, TaxType $taxType): bool + { + return $this->mayRemove($user, $taxType); + } + + private function mayRemove(User $user, TaxType $taxType): bool + { + return BouncerFacade::can('delete-tax-type', $taxType) && $this->sameCompany($user, $taxType); + } + + private function sameCompany(User $user, TaxType $taxType): bool + { + return $user->hasCompany($taxType->company_id); + } +}