From 885042f13ae5d712766e84438b1cb8209fb29ab9 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski <5760249+gdarko@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:01:01 +0200 Subject: [PATCH] feat(expenses): add tax tracking and reporting (#737) --- .../Company/Expense/ExpensesController.php | 4 +- .../Company/Item/ItemsController.php | 5 +- .../Report/TaxSummaryReportController.php | 19 +- app/Http/Requests/ExpenseRequest.php | 58 +++ app/Http/Requests/TaxTypeRequest.php | 20 +- .../Resources/Customer/TaxTypeResource.php | 1 + app/Http/Resources/ExpenseResource.php | 1 + app/Http/Resources/TaxResource.php | 1 + app/Http/Resources/TaxTypeResource.php | 1 + app/Models/Expense.php | 13 + app/Models/Tax.php | 45 ++- app/Models/TaxType.php | 13 + app/Services/Company/CompanyService.php | 2 +- app/Services/CustomerService.php | 2 +- app/Services/Document/ExpenseService.php | 64 ++- database/factories/TaxTypeFactory.php | 1 + ...1_233036_add_expense_id_to_taxes_table.php | 29 ++ ...dd_transaction_type_to_tax_types_table.php | 31 ++ lang/en.json | 21 +- .../scripts/api/services/tax-type.service.ts | 23 +- .../expenses/components/ExpenseTaxSection.vue | 369 ++++++++++++++++++ .../features/company/expenses/store.ts | 4 + .../expenses/views/ExpenseCreateView.vue | 59 +-- .../features/company/items/use-tax-types.ts | 5 +- .../settings/components/TaxTypeModal.vue | 71 +++- .../company/settings/views/TaxTypesView.vue | 21 +- .../document-form/DocumentItemRowTax.vue | 22 +- .../shared/document-form/DocumentTotals.vue | 5 +- .../shared/document-form/TaxSelectPopup.vue | 19 +- resources/scripts/types/domain/expense.ts | 4 + resources/scripts/types/domain/tax.ts | 24 ++ .../app/pdf/reports/tax-summary.blade.php | 64 ++- tests/Feature/Admin/ExpenseTest.php | 181 +++++++++ tests/Feature/Admin/ItemTest.php | 23 ++ tests/Feature/Admin/TaxTypeTest.php | 69 ++++ tests/Feature/Pdf/TaxSummaryReportTest.php | 190 +++++++++ tests/Unit/ExpenseTest.php | 48 +++ tests/Unit/TaxTypeTest.php | 12 + 38 files changed, 1466 insertions(+), 78 deletions(-) create mode 100644 database/migrations/2026_08_01_233036_add_expense_id_to_taxes_table.php create mode 100644 database/migrations/2026_08_01_234707_add_transaction_type_to_tax_types_table.php create mode 100644 resources/scripts/features/company/expenses/components/ExpenseTaxSection.vue create mode 100644 tests/Feature/Pdf/TaxSummaryReportTest.php diff --git a/app/Http/Controllers/Company/Expense/ExpensesController.php b/app/Http/Controllers/Company/Expense/ExpensesController.php index 0a9ca30b..e1a244e1 100644 --- a/app/Http/Controllers/Company/Expense/ExpensesController.php +++ b/app/Http/Controllers/Company/Expense/ExpensesController.php @@ -66,6 +66,8 @@ class ExpensesController extends Controller { $this->authorize('view', $expense); + $expense->load('taxes.taxType'); + return new ExpenseResource($expense); } @@ -78,7 +80,7 @@ class ExpensesController extends Controller { $this->authorize('update', $expense); - $this->expenseService->update($expense, $request); + $expense = $this->expenseService->update($expense, $request); return new ExpenseResource($expense); } diff --git a/app/Http/Controllers/Company/Item/ItemsController.php b/app/Http/Controllers/Company/Item/ItemsController.php index c50921aa..380aa956 100644 --- a/app/Http/Controllers/Company/Item/ItemsController.php +++ b/app/Http/Controllers/Company/Item/ItemsController.php @@ -38,7 +38,10 @@ class ItemsController extends Controller return ItemResource::collection($items) ->additional(['meta' => [ - 'tax_types' => TaxType::whereCompany()->latest()->get(), + 'tax_types' => TaxType::whereCompany() + ->whereTransactionType(TaxType::TRANSACTION_TYPE_SALES) + ->latest() + ->get(), 'item_total_count' => Item::whereCompany()->count(), ]]); } diff --git a/app/Http/Controllers/Company/Report/TaxSummaryReportController.php b/app/Http/Controllers/Company/Report/TaxSummaryReportController.php index 3430f572..551605cd 100644 --- a/app/Http/Controllers/Company/Report/TaxSummaryReportController.php +++ b/app/Http/Controllers/Company/Report/TaxSummaryReportController.php @@ -40,16 +40,22 @@ class TaxSummaryReportController extends Controller App::setLocale($locale); - $taxTypes = Tax::with('taxType', 'invoice', 'invoiceItem') + $taxTypes = Tax::with('taxType') ->whereCompany($company->id) ->whereInvoicesFilters($request->only(['from_date', 'to_date'])) ->taxAttributes() ->get(); - $totalAmount = 0; - foreach ($taxTypes as $taxType) { - $totalAmount += $taxType->total_tax_amount; - } + $totalAmount = (int) $taxTypes->sum('total_tax_amount'); + + $expenseTaxTypes = Tax::with('taxType') + ->whereCompany($company->id) + ->whereExpensesFilters($request->only(['from_date', 'to_date'])) + ->taxAttributes() + ->get(); + + $totalExpenseTaxAmount = (int) $expenseTaxTypes->sum('total_tax_amount'); + $netTaxAmount = $totalAmount - $totalExpenseTaxAmount; $dateFormat = CompanySetting::getSetting('carbon_date_format', $company->id); $from_date = Carbon::createFromFormat('Y-m-d', $request->from_date)->translatedFormat($dateFormat); @@ -75,6 +81,9 @@ class TaxSummaryReportController extends Controller view()->share([ 'taxTypes' => $taxTypes, 'totalTaxAmount' => $totalAmount, + 'expenseTaxTypes' => $expenseTaxTypes, + 'totalExpenseTaxAmount' => $totalExpenseTaxAmount, + 'netTaxAmount' => $netTaxAmount, 'colorSettings' => $colorSettings, 'company' => $company, 'from_date' => $from_date, diff --git a/app/Http/Requests/ExpenseRequest.php b/app/Http/Requests/ExpenseRequest.php index 77829784..785c3b6a 100644 --- a/app/Http/Requests/ExpenseRequest.php +++ b/app/Http/Requests/ExpenseRequest.php @@ -3,10 +3,24 @@ namespace App\Http\Requests; use App\Models\CompanySetting; +use App\Models\TaxType; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class ExpenseRequest extends FormRequest { + protected function prepareForValidation(): void + { + if (is_string($this->input('taxes'))) { + $taxes = json_decode($this->input('taxes'), true); + + $this->merge([ + 'taxes' => json_last_error() === JSON_ERROR_NONE ? $taxes : null, + ]); + } + } + /** * Determine if the user is authorized to make this request. */ @@ -42,6 +56,8 @@ class ExpenseRequest extends FormRequest ], 'amount' => [ 'required', + 'integer', + 'min:0', ], 'customer_id' => [ 'nullable', @@ -58,6 +74,28 @@ class ExpenseRequest extends FormRequest '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', + ], ]; if ($companyCurrency && $this->currency_id) { @@ -71,6 +109,25 @@ class ExpenseRequest extends FormRequest return $rules; } + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $taxes = $this->input('taxes'); + + if (! is_array($taxes) || $validator->errors()->has('taxes') || $validator->errors()->has('taxes.*')) { + return; + } + + $totalTaxAmount = collect($taxes)->sum( + fn (mixed $tax): int => is_array($tax) ? (int) ($tax['amount'] ?? 0) : 0 + ); + + if ($totalTaxAmount > (int) $this->input('amount')) { + $validator->errors()->add('taxes', 'The total tax amount may not exceed the expense amount.'); + } + }); + } + public function getExpensePayload() { $company_currency = CompanySetting::getSetting('currency', $this->header('company')); @@ -78,6 +135,7 @@ class ExpenseRequest extends FormRequest $exchange_rate = $company_currency != $current_currency ? $this->exchange_rate : 1; return collect($this->validated()) + ->except('taxes') ->merge([ 'creator_id' => $this->user()->id, 'company_id' => $this->header('company'), diff --git a/app/Http/Requests/TaxTypeRequest.php b/app/Http/Requests/TaxTypeRequest.php index c16be432..3afbaeaa 100644 --- a/app/Http/Requests/TaxTypeRequest.php +++ b/app/Http/Requests/TaxTypeRequest.php @@ -49,6 +49,13 @@ class TaxTypeRequest extends FormRequest 'collective_tax' => [ 'nullable', ], + 'transaction_type' => [ + 'sometimes', + Rule::in([ + TaxType::TRANSACTION_TYPE_SALES, + TaxType::TRANSACTION_TYPE_PURCHASES, + ]), + ], ]; if ($this->isMethod('PUT')) { @@ -66,7 +73,18 @@ class TaxTypeRequest extends FormRequest public function getTaxTypePayload() { - return collect($this->validated()) + $payload = collect($this->validated()); + + if (! $payload->has('transaction_type')) { + $payload->put( + 'transaction_type', + ($this->isMethod('PUT') || $this->isMethod('PATCH')) + ? $this->route('tax_type')->transaction_type + : TaxType::TRANSACTION_TYPE_SALES + ); + } + + return $payload ->merge([ 'company_id' => $this->header('company'), 'type' => TaxType::TYPE_GENERAL, diff --git a/app/Http/Resources/Customer/TaxTypeResource.php b/app/Http/Resources/Customer/TaxTypeResource.php index 6f3de3b3..e2a5f26b 100644 --- a/app/Http/Resources/Customer/TaxTypeResource.php +++ b/app/Http/Resources/Customer/TaxTypeResource.php @@ -18,6 +18,7 @@ class TaxTypeResource extends JsonResource 'id' => $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, diff --git a/app/Http/Resources/ExpenseResource.php b/app/Http/Resources/ExpenseResource.php index 4f229785..a49d12fc 100644 --- a/app/Http/Resources/ExpenseResource.php +++ b/app/Http/Resources/ExpenseResource.php @@ -33,6 +33,7 @@ class ExpenseResource extends JsonResource 'currency_id' => $this->currency_id, 'base_amount' => $this->base_amount, 'payment_method_id' => $this->payment_method_id, + 'taxes' => TaxResource::collection($this->whenLoaded('taxes')), 'customer' => $this->when($this->customer()->exists(), function () { return new CustomerResource($this->customer); }), diff --git a/app/Http/Resources/TaxResource.php b/app/Http/Resources/TaxResource.php index a0ccc1d4..81c7b9ed 100644 --- a/app/Http/Resources/TaxResource.php +++ b/app/Http/Resources/TaxResource.php @@ -21,6 +21,7 @@ class TaxResource extends JsonResource '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, diff --git a/app/Http/Resources/TaxTypeResource.php b/app/Http/Resources/TaxTypeResource.php index aa8cb2e8..845cbd32 100644 --- a/app/Http/Resources/TaxTypeResource.php +++ b/app/Http/Resources/TaxTypeResource.php @@ -21,6 +21,7 @@ class TaxTypeResource extends JsonResource '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, diff --git a/app/Models/Expense.php b/app/Models/Expense.php index 4b135584..b800cb5a 100644 --- a/app/Models/Expense.php +++ b/app/Models/Expense.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\DB; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; @@ -43,6 +44,13 @@ class Expense extends Model implements HasMedia ]; } + protected static function booted(): void + { + static::deleting(function (Expense $expense): void { + $expense->taxes()->delete(); + }); + } + public function registerMediaCollections(): void { $this->addMediaCollection('receipts'); @@ -78,6 +86,11 @@ class Expense extends Model implements HasMedia return $this->belongsTo(User::class, 'creator_id'); } + public function taxes(): HasMany + { + return $this->hasMany(Tax::class); + } + public function getFormattedExpenseDateAttribute(mixed $value): string { $dateFormat = CompanySetting::getSetting('carbon_date_format', $this->company_id); diff --git a/app/Models/Tax.php b/app/Models/Tax.php index f56d37d0..6234390c 100644 --- a/app/Models/Tax.php +++ b/app/Models/Tax.php @@ -23,6 +23,7 @@ class Tax extends Model 'amount' => 'integer', 'percent' => 'float', 'fixed_amount' => 'integer', + 'compound_tax' => 'boolean', ]; } @@ -56,6 +57,11 @@ class Tax extends Model return $this->belongsTo(InvoiceItem::class); } + public function expense(): BelongsTo + { + return $this->belongsTo(Expense::class); + } + public function estimateItem(): BelongsTo { return $this->belongsTo(EstimateItem::class); @@ -80,20 +86,21 @@ class Tax extends Model public function scopeInvoicesBetween(Builder $query, Carbon $start, Carbon $end): void { - $query->whereHas('invoice', function ($query) use ($start, $end) { - $query->where('paid_status', Invoice::STATUS_PAID) - ->whereBetween( - 'invoice_date', - [$start->format('Y-m-d'), $end->format('Y-m-d')] - ); - }) - ->orWhereHas('invoiceItem.invoice', function ($query) use ($start, $end) { + $query->where(function (Builder $query) use ($start, $end) { + $query->whereHas('invoice', function (Builder $query) use ($start, $end) { + $query->where('paid_status', Invoice::STATUS_PAID) + ->whereBetween( + 'invoice_date', + [$start->format('Y-m-d'), $end->format('Y-m-d')] + ); + })->orWhereHas('invoiceItem.invoice', function (Builder $query) use ($start, $end) { $query->where('paid_status', Invoice::STATUS_PAID) ->whereBetween( 'invoice_date', [$start->format('Y-m-d'), $end->format('Y-m-d')] ); }); + }); } public function scopeWhereInvoicesFilters(Builder $query, array $filters): void @@ -107,4 +114,26 @@ class Tax extends Model $query->invoicesBetween($start, $end); } } + + public function scopeExpensesBetween(Builder $query, Carbon $start, Carbon $end): void + { + $query->whereHas('expense', function (Builder $query) use ($start, $end) { + $query->whereBetween( + 'expense_date', + [$start->format('Y-m-d'), $end->format('Y-m-d')] + ); + }); + } + + public function scopeWhereExpensesFilters(Builder $query, array $filters): void + { + $filters = collect($filters); + + if ($filters->get('from_date') && $filters->get('to_date')) { + $start = Carbon::createFromFormat('Y-m-d', $filters->get('from_date')); + $end = Carbon::createFromFormat('Y-m-d', $filters->get('to_date')); + + $query->expensesBetween($start, $end); + } + } } diff --git a/app/Models/TaxType.php b/app/Models/TaxType.php index b2d6adc7..9ee3c567 100644 --- a/app/Models/TaxType.php +++ b/app/Models/TaxType.php @@ -32,6 +32,10 @@ class TaxType extends Model public const TYPE_MODULE = 'MODULE'; + public const TRANSACTION_TYPE_SALES = 'sales'; + + public const TRANSACTION_TYPE_PURCHASES = 'purchases'; + public function taxes(): HasMany { return $this->hasMany(Tax::class); @@ -52,6 +56,11 @@ class TaxType extends Model $query->orWhere('id', $tax_type_id); } + public function scopeWhereTransactionType(Builder $query, string $transaction_type): void + { + $query->where('transaction_type', $transaction_type); + } + public function scopeApplyFilters(Builder $query, array $filters): void { $filters = collect($filters); @@ -64,6 +73,10 @@ class TaxType extends Model $query->whereCompany($filters->get('company_id')); } + if ($filters->get('transaction_type')) { + $query->whereTransactionType($filters->get('transaction_type')); + } + if ($filters->get('search')) { $query->whereSearch($filters->get('search')); } diff --git a/app/Services/Company/CompanyService.php b/app/Services/Company/CompanyService.php index 86ecf75d..5afb98dd 100644 --- a/app/Services/Company/CompanyService.php +++ b/app/Services/Company/CompanyService.php @@ -48,7 +48,7 @@ class CompanyService } if ($company->expenses()->exists()) { - $company->expenses()->delete(); + $company->expenses->each->delete(); } if ($company->expenseCategories()->exists()) { diff --git a/app/Services/CustomerService.php b/app/Services/CustomerService.php index 34673eb7..aa3e0405 100644 --- a/app/Services/CustomerService.php +++ b/app/Services/CustomerService.php @@ -104,7 +104,7 @@ class CustomerService } if ($customer->expenses()->exists()) { - $customer->expenses()->delete(); + $customer->expenses->each->delete(); } if ($customer->recurringInvoices()->exists()) { diff --git a/app/Services/Document/ExpenseService.php b/app/Services/Document/ExpenseService.php index 6a20406e..bb77f967 100644 --- a/app/Services/Document/ExpenseService.php +++ b/app/Services/Document/ExpenseService.php @@ -5,13 +5,23 @@ namespace App\Services\Document; use App\Models\CompanySetting; use App\Models\ExchangeRateLog; use App\Models\Expense; +use App\Models\TaxType; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; class ExpenseService { public function create(Request $request): Expense { - $expense = Expense::create($request->getExpensePayload()); + $expense = DB::transaction(function () use ($request): Expense { + $expense = Expense::create($request->getExpensePayload()); + + if ($request->has('taxes')) { + $this->syncTaxes($expense, $request->input('taxes')); + } + + return $expense; + }); $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); @@ -27,14 +37,20 @@ class ExpenseService $expense->addCustomFields(json_decode($request->customFields)); } - return $expense; + return $expense->load('taxes.taxType'); } - public function update(Expense $expense, Request $request): bool + public function update(Expense $expense, Request $request): Expense { $data = $request->getExpensePayload(); - $expense->update($data); + DB::transaction(function () use ($expense, $data, $request): void { + $expense->update($data); + + if ($request->has('taxes')) { + $this->syncTaxes($expense, $request->input('taxes')); + } + }); $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); @@ -54,6 +70,44 @@ class ExpenseService $expense->updateCustomFields(json_decode($request->customFields)); } - return true; + return $expense->fresh('taxes.taxType'); + } + + /** + * Replace an expense's receipt tax snapshots with the submitted tax amounts. + */ + private function syncTaxes(Expense $expense, array $taxes): void + { + $expense->taxes()->delete(); + + if ($taxes === []) { + return; + } + + $taxTypes = TaxType::query() + ->where('company_id', $expense->company_id) + ->where('type', TaxType::TYPE_GENERAL) + ->whereTransactionType(TaxType::TRANSACTION_TYPE_PURCHASES) + ->whereIn('id', collect($taxes)->pluck('tax_type_id')) + ->get() + ->keyBy('id'); + + foreach ($taxes as $tax) { + $taxType = $taxTypes->get($tax['tax_type_id']); + + $expense->taxes()->create([ + 'tax_type_id' => $taxType->id, + 'company_id' => $expense->company_id, + 'currency_id' => $expense->currency_id, + 'exchange_rate' => $expense->exchange_rate, + 'amount' => (int) $tax['amount'], + 'base_amount' => (int) round($tax['amount'] * $expense->exchange_rate), + 'name' => $taxType->name, + 'percent' => $taxType->percent, + 'fixed_amount' => $taxType->fixed_amount, + 'calculation_type' => $taxType->calculation_type, + 'compound_tax' => $taxType->compound_tax, + ]); + } } } diff --git a/database/factories/TaxTypeFactory.php b/database/factories/TaxTypeFactory.php index 895e0fdb..004eaf12 100644 --- a/database/factories/TaxTypeFactory.php +++ b/database/factories/TaxTypeFactory.php @@ -23,6 +23,7 @@ class TaxTypeFactory extends Factory return [ 'name' => $this->faker->word(), 'calculation_type' => 'percentage', + 'transaction_type' => TaxType::TRANSACTION_TYPE_SALES, 'company_id' => User::find(1)->companies()->first()->id, 'percent' => $this->faker->numberBetween($min = 0, $max = 100), 'fixed_amount' => null, diff --git a/database/migrations/2026_08_01_233036_add_expense_id_to_taxes_table.php b/database/migrations/2026_08_01_233036_add_expense_id_to_taxes_table.php new file mode 100644 index 00000000..bb3f3091 --- /dev/null +++ b/database/migrations/2026_08_01_233036_add_expense_id_to_taxes_table.php @@ -0,0 +1,29 @@ +unsignedInteger('expense_id')->nullable()->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('taxes', function (Blueprint $table) { + $table->dropIndex(['expense_id']); + $table->dropColumn('expense_id'); + }); + } +}; diff --git a/database/migrations/2026_08_01_234707_add_transaction_type_to_tax_types_table.php b/database/migrations/2026_08_01_234707_add_transaction_type_to_tax_types_table.php new file mode 100644 index 00000000..5948358c --- /dev/null +++ b/database/migrations/2026_08_01_234707_add_transaction_type_to_tax_types_table.php @@ -0,0 +1,31 @@ +string('transaction_type', 20) + ->default('sales') + ->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tax_types', function (Blueprint $table) { + $table->dropIndex(['transaction_type']); + $table->dropColumn('transaction_type'); + }); + } +}; diff --git a/lang/en.json b/lang/en.json index 55e191a0..13bda1d7 100644 --- a/lang/en.json +++ b/lang/en.json @@ -181,7 +181,10 @@ "compound_tax": "Compound Tax", "percentage": "Percentage", "fixed_amount": "Fixed Amount", - "tax_type": "Tax Type" + "tax_type": "Tax Type", + "used_for": "Used for", + "sales": "Sales", + "purchases": "Purchases" }, "global_search": { "search": "Search...", @@ -709,6 +712,17 @@ "description": "Description", "receipt": "Receipt", "amount": "Amount", + "gross": "Gross", + "input_tax": "Input Tax", + "net": "Net", + "taxes": "Taxes", + "taxes_description": "Record the purchase tax included in this expense.", + "add_tax": "Add Tax", + "add_new_tax": "Add New Tax", + "tax_amount": "Tax Amount", + "tax_summary": "Tax summary", + "no_taxes": "No taxes added", + "no_taxes_description": "Add a purchase tax when it applies to this expense.", "action": "Action", "not_selected": "Not selected", "note": "Note", @@ -1907,6 +1921,11 @@ "pdf_tax_report_label": "TAX REPORT", "pdf_total_tax_label": "TOTAL TAX", "pdf_tax_types_label": "Tax Types", + "pdf_output_tax_label": "Output Tax", + "pdf_input_tax_label": "Input Tax", + "pdf_tax_payable_label": "Tax Payable", + "pdf_tax_refundable_label": "Tax Refundable", + "pdf_tax_balance_label": "Tax Balance", "pdf_expenses_label": "Expenses", "pdf_expense_group_total_label": "Group total:", "pdf_bill_to": "Bill to,", diff --git a/resources/scripts/api/services/tax-type.service.ts b/resources/scripts/api/services/tax-type.service.ts index d35d91a2..bbc84034 100644 --- a/resources/scripts/api/services/tax-type.service.ts +++ b/resources/scripts/api/services/tax-type.service.ts @@ -1,20 +1,37 @@ import { client } from '../client' import { API } from '../endpoints' -import type { TaxType } from '@/scripts/types/domain/tax' -import type { ApiResponse, ListParams } from '@/scripts/types/api' +import type { + TaxType, + TaxTypeTransactionType, +} from '@/scripts/types/domain/tax' +import type { + ApiResponse, + ListParams, + PaginationMeta, +} from '@/scripts/types/api' + +export interface TaxTypeListParams extends ListParams { + transaction_type?: TaxTypeTransactionType +} + +export interface TaxTypeListResponse { + data: TaxType[] + meta: PaginationMeta +} export interface CreateTaxTypePayload { name: string percent: number fixed_amount?: number calculation_type?: string | null + transaction_type: TaxTypeTransactionType compound_tax?: boolean collective_tax?: number | null description?: string | null } export const taxTypeService = { - async list(params?: ListParams): Promise> { + async list(params?: TaxTypeListParams): Promise { const { data } = await client.get(API.TAX_TYPES, { params }) return data }, diff --git a/resources/scripts/features/company/expenses/components/ExpenseTaxSection.vue b/resources/scripts/features/company/expenses/components/ExpenseTaxSection.vue new file mode 100644 index 00000000..91f22759 --- /dev/null +++ b/resources/scripts/features/company/expenses/components/ExpenseTaxSection.vue @@ -0,0 +1,369 @@ + + + diff --git a/resources/scripts/features/company/expenses/store.ts b/resources/scripts/features/company/expenses/store.ts index 8981b1d4..69eaa4f7 100644 --- a/resources/scripts/features/company/expenses/store.ts +++ b/resources/scripts/features/company/expenses/store.ts @@ -9,6 +9,7 @@ import type { ExpenseCategory, CreateExpensePayload, } from '../../../types/domain/expense' +import type { ExpenseTax } from '../../../types/domain/tax' import type { PaymentMethod } from '../../../types/domain/payment' import type { Currency } from '../../../types/domain/currency' import type { CustomFieldValue } from '../../../types/domain/custom-field' @@ -40,6 +41,7 @@ export interface ExpenseFormData { receiptFiles: ReceiptFile[] customFields: CustomFieldValue[] fields: CustomFieldValue[] + taxes: ExpenseTax[] } function createExpenseStub(): ExpenseFormData { @@ -60,6 +62,7 @@ function createExpenseStub(): ExpenseFormData { receiptFiles: [], customFields: [], fields: [], + taxes: [], } } @@ -112,6 +115,7 @@ export const useExpenseStore = defineStore('expense', { const data = response.data Object.assign(this.currentExpense, data) + this.currentExpense.taxes = data.taxes ?? [] this.currentExpense.selectedCurrency = data.currency ?? null this.currentExpense.attachment_receipt = null diff --git a/resources/scripts/features/company/expenses/views/ExpenseCreateView.vue b/resources/scripts/features/company/expenses/views/ExpenseCreateView.vue index 4b6d28a4..df475a89 100644 --- a/resources/scripts/features/company/expenses/views/ExpenseCreateView.vue +++ b/resources/scripts/features/company/expenses/views/ExpenseCreateView.vue @@ -213,30 +213,38 @@ /> - -
- - - {{ - isEdit - ? $t('expenses.update_expense') - : $t('expenses.save_expense') - }} - -
+ + + + +
+ + + {{ + isEdit + ? $t('expenses.update_expense') + : $t('expenses.save_expense') + }} + +
@@ -250,6 +258,7 @@ import { useExpenseStore } from '../store' import { useGlobalStore } from '../../../../stores/global.store' import { useCompanyStore } from '../../../../stores/company.store' import { ExchangeRateConverter } from '../../../shared/document-form' +import ExpenseTaxSection from '../components/ExpenseTaxSection.vue' import type { ExpenseCategory } from '../../../../types/domain/expense' import type { Customer } from '../../../../types/domain/customer' import type { Currency } from '../../../../types/domain/currency' @@ -349,6 +358,10 @@ async function submitForm(): Promise { const formData: Record = { ...expenseStore.currentExpense, expense_number: expenseStore.currentExpense.expense_number || '', + taxes: expenseStore.currentExpense.taxes.map((tax) => ({ + tax_type_id: tax.tax_type_id, + amount: tax.amount, + })), } try { diff --git a/resources/scripts/features/company/items/use-tax-types.ts b/resources/scripts/features/company/items/use-tax-types.ts index 91aea6d4..68cac0e8 100644 --- a/resources/scripts/features/company/items/use-tax-types.ts +++ b/resources/scripts/features/company/items/use-tax-types.ts @@ -12,7 +12,10 @@ export function useTaxTypes(): { async function fetchTaxTypes(): Promise { try { - const response = await taxTypeService.list({ limit: 'all' }) + const response = await taxTypeService.list({ + limit: 'all', + transaction_type: 'sales', + }) taxTypes.value = response.data return taxTypes.value } catch (error: unknown) { diff --git a/resources/scripts/features/company/settings/components/TaxTypeModal.vue b/resources/scripts/features/company/settings/components/TaxTypeModal.vue index 5a86f2af..fadebaf3 100644 --- a/resources/scripts/features/company/settings/components/TaxTypeModal.vue +++ b/resources/scripts/features/company/settings/components/TaxTypeModal.vue @@ -14,16 +14,25 @@ import { useCompanyStore } from '@/scripts/stores/company.store' import { useNotificationStore } from '@/scripts/stores/notification.store' import { taxTypeService } from '@/scripts/api/services/tax-type.service' import type { CreateTaxTypePayload } from '@/scripts/api/services/tax-type.service' +import type { + TaxType, + TaxTypeTransactionType, +} from '@/scripts/types/domain/tax' interface TaxTypeForm { id: number | null name: string calculation_type: string + transaction_type: TaxTypeTransactionType percent: number fixed_amount: number description: string } +interface TaxTypeModalContext { + transaction_type?: TaxTypeTransactionType +} + const modalStore = useModalStore() const companyStore = useCompanyStore() const notificationStore = useNotificationStore() @@ -36,6 +45,7 @@ const currentTaxType = ref({ id: null, name: '', calculation_type: 'percentage', + transaction_type: 'sales', percent: 0, fixed_amount: 0, description: '', @@ -58,6 +68,9 @@ const rules = computed(() => ({ calculation_type: { required: helpers.withMessage(t('validation.required'), required), }, + transaction_type: { + required: helpers.withMessage(t('validation.required'), required), + }, percent: { required: helpers.withMessage(t('validation.required'), required), between: helpers.withMessage( @@ -95,6 +108,7 @@ async function setInitialData(): Promise { id: tax.id, name: tax.name, calculation_type: tax.calculation_type ?? 'percentage', + transaction_type: tax.transaction_type, percent: tax.percent, fixed_amount: tax.fixed_amount, description: tax.description ?? '', @@ -103,6 +117,10 @@ async function setInitialData(): Promise { } else { isEdit.value = false resetForm() + const context = getModalContext(modalStore.data) + if (context?.transaction_type) { + currentTaxType.value.transaction_type = context.transaction_type + } } } @@ -119,17 +137,21 @@ async function submitTaxTypeData(): Promise { percent: currentTaxType.value.percent, fixed_amount: currentTaxType.value.fixed_amount, calculation_type: currentTaxType.value.calculation_type, + transaction_type: currentTaxType.value.transaction_type, description: currentTaxType.value.description || null, } + let savedTaxType: TaxType if (isEdit.value && currentTaxType.value.id) { - await taxTypeService.update(currentTaxType.value.id, payload) + const response = await taxTypeService.update(currentTaxType.value.id, payload) + savedTaxType = response.data notificationStore.showNotification({ type: 'success', message: 'settings.tax_types.updated_message', }) } else { - await taxTypeService.create(payload) + const response = await taxTypeService.create(payload) + savedTaxType = response.data notificationStore.showNotification({ type: 'success', message: 'settings.tax_types.created_message', @@ -138,7 +160,7 @@ async function submitTaxTypeData(): Promise { isSaving.value = false if (modalStore.refreshData) { - modalStore.refreshData() + modalStore.refreshData(savedTaxType) } closeTaxTypeModal() } catch { @@ -151,12 +173,30 @@ function resetForm(): void { id: null, name: '', calculation_type: 'percentage', + transaction_type: 'sales', percent: 0, fixed_amount: 0, description: '', } } +function getModalContext(data: unknown): TaxTypeModalContext | null { + if ( + typeof data !== 'object' || + data === null || + !('transaction_type' in data) + ) { + return null + } + + const transactionType = data.transaction_type + if (transactionType !== 'sales' && transactionType !== 'purchases') { + return null + } + + return { transaction_type: transactionType } +} + function closeTaxTypeModal(): void { modalStore.closeModal() setTimeout(() => { @@ -219,6 +259,31 @@ function closeTaxTypeModal(): void { /> + + + + - sort: { fieldName: string; order: string } + sort: { fieldName: string; order: 'asc' | 'desc' } } interface FetchResult { @@ -66,6 +66,12 @@ const taxTypeColumns = computed(() => [ thClass: 'extra', tdClass: 'font-medium text-heading', }, + { + key: 'transaction_type', + label: t('tax_types.used_for'), + thClass: 'extra', + tdClass: 'font-medium text-heading', + }, { key: 'actions', label: '', @@ -140,11 +146,11 @@ async function fetchData({ page, sort }: FetchParams): Promise { const response = await taxTypeService.list(data) return { - data: (response as Record).data as unknown[], + data: response.data, pagination: { - totalPages: ((response as Record).meta as Record).last_page, + totalPages: response.meta.last_page, currentPage: page, - totalCount: ((response as Record).meta as Record).total, + totalCount: response.meta.total, limit: 5, }, } @@ -159,6 +165,7 @@ function openTaxModal(): void { title: t('settings.tax_types.add_tax'), componentName: 'TaxTypeModal', size: 'sm', + data: { transaction_type: 'sales' }, refreshData: table.value?.refresh, }) } @@ -200,6 +207,12 @@ function openTaxModal(): void { + +