Skip to content

Commit 7d867b3

Browse files
committed
feat(finance): Phase 70 — Tax Management with rates, groups, and compound tax
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent de9c1e9 commit 7d867b3

20 files changed

Lines changed: 1226 additions & 0 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\TaxGroup;
7+
use App\Modules\Finance\Models\TaxGroupItem;
8+
use App\Modules\Finance\Models\TaxRate;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class TaxGroupController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$this->authorize('viewAny', TaxGroup::class);
19+
20+
$taxGroups = TaxGroup::withCount('items')
21+
->latest()
22+
->paginate(15)
23+
->withQueryString();
24+
25+
return Inertia::render('Finance/TaxGroups/Index', [
26+
'taxGroups' => $taxGroups,
27+
]);
28+
}
29+
30+
public function create(): Response
31+
{
32+
$taxRates = TaxRate::active()->orderBy('name')->get();
33+
34+
return Inertia::render('Finance/TaxGroups/Create', [
35+
'taxRates' => $taxRates,
36+
]);
37+
}
38+
39+
public function store(Request $request): RedirectResponse
40+
{
41+
$this->authorize('create', TaxGroup::class);
42+
43+
$validated = $request->validate([
44+
'name' => ['required', 'string', 'max:255'],
45+
'description' => ['nullable', 'string'],
46+
]);
47+
48+
$taxGroup = TaxGroup::create([
49+
...$validated,
50+
'tenant_id' => auth()->user()->tenant_id,
51+
]);
52+
53+
return redirect()->route('finance.tax-groups.show', $taxGroup)
54+
->with('success', 'Tax group created successfully.');
55+
}
56+
57+
public function show(TaxGroup $taxGroup): Response
58+
{
59+
$taxGroup->load('items.taxRate');
60+
61+
return Inertia::render('Finance/TaxGroups/Show', [
62+
'taxGroup' => $taxGroup->append('total_rate'),
63+
]);
64+
}
65+
66+
public function destroy(TaxGroup $taxGroup): RedirectResponse
67+
{
68+
$this->authorize('delete', $taxGroup);
69+
70+
$taxGroup->delete();
71+
72+
return redirect()->route('finance.tax-groups.index')
73+
->with('success', 'Tax group deleted successfully.');
74+
}
75+
76+
public function addRate(Request $request, TaxGroup $taxGroup): RedirectResponse
77+
{
78+
$this->authorize('create', $taxGroup);
79+
80+
$validated = $request->validate([
81+
'tax_rate_id' => ['required', 'exists:tax_rates,id'],
82+
]);
83+
84+
TaxGroupItem::updateOrCreate(
85+
[
86+
'tax_group_id' => $taxGroup->id,
87+
'tax_rate_id' => $validated['tax_rate_id'],
88+
],
89+
[
90+
'tenant_id' => auth()->user()->tenant_id,
91+
]
92+
);
93+
94+
return redirect()->back()->with('success', 'Tax rate added to group.');
95+
}
96+
97+
public function removeRate(TaxGroup $taxGroup, TaxGroupItem $item): RedirectResponse
98+
{
99+
$this->authorize('delete', $taxGroup);
100+
101+
$item->delete();
102+
103+
return redirect()->back()->with('success', 'Tax rate removed from group.');
104+
}
105+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\TaxRate;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class TaxRateController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', TaxRate::class);
17+
18+
$taxRates = TaxRate::when($request->tax_type, fn ($q) => $q->where('tax_type', $request->tax_type))
19+
->latest()
20+
->paginate(20)
21+
->withQueryString();
22+
23+
return Inertia::render('Finance/TaxRates/Index', [
24+
'taxRates' => $taxRates,
25+
'filters' => $request->only(['tax_type']),
26+
]);
27+
}
28+
29+
public function create(): Response
30+
{
31+
return Inertia::render('Finance/TaxRates/Create');
32+
}
33+
34+
public function store(Request $request): RedirectResponse
35+
{
36+
$this->authorize('create', TaxRate::class);
37+
38+
$validated = $request->validate([
39+
'name' => ['required', 'string', 'max:255'],
40+
'rate' => ['required', 'numeric', 'min:0', 'max:100'],
41+
'tax_type' => ['required', 'in:sales,purchase,both'],
42+
'is_compound' => ['boolean'],
43+
'is_active' => ['boolean'],
44+
]);
45+
46+
TaxRate::create([
47+
...$validated,
48+
'tenant_id' => auth()->user()->tenant_id,
49+
]);
50+
51+
return redirect()->route('finance.tax-rates.index')
52+
->with('success', 'Tax rate created successfully.');
53+
}
54+
55+
public function show(TaxRate $taxRate): Response
56+
{
57+
return Inertia::render('Finance/TaxRates/Show', [
58+
'taxRate' => $taxRate->load('taxGroupItems'),
59+
]);
60+
}
61+
62+
public function destroy(TaxRate $taxRate): RedirectResponse
63+
{
64+
$this->authorize('delete', $taxRate);
65+
66+
$taxRate->delete();
67+
68+
return redirect()->route('finance.tax-rates.index')
69+
->with('success', 'Tax rate deleted successfully.');
70+
}
71+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\HasMany;
8+
use Illuminate\Database\Eloquent\SoftDeletes;
9+
10+
class TaxGroup extends Model
11+
{
12+
use BelongsToTenant;
13+
use SoftDeletes;
14+
15+
protected $table = 'tax_groups';
16+
17+
protected $fillable = [
18+
'tenant_id', 'name', 'description', 'is_active',
19+
];
20+
21+
protected $casts = [
22+
'is_active' => 'boolean',
23+
];
24+
25+
public function items(): HasMany
26+
{
27+
return $this->hasMany(TaxGroupItem::class);
28+
}
29+
30+
public function calculateTotalTax(float $amount): float
31+
{
32+
if (!$this->relationLoaded('items')) {
33+
$this->load('items.taxRate');
34+
}
35+
36+
$sum = 0.0;
37+
foreach ($this->items as $item) {
38+
if ($item->taxRate) {
39+
$sum += $item->taxRate->calculateTax($amount);
40+
}
41+
}
42+
return (float) $sum;
43+
}
44+
45+
public function getTotalRateAttribute(): float
46+
{
47+
if (!$this->relationLoaded('items')) {
48+
$this->load('items.taxRate');
49+
}
50+
51+
$sum = 0.0;
52+
foreach ($this->items as $item) {
53+
if ($item->taxRate) {
54+
$sum += (float) $item->taxRate->rate;
55+
}
56+
}
57+
return $sum;
58+
}
59+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class TaxGroupItem extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $table = 'tax_group_items';
14+
15+
protected $fillable = [
16+
'tenant_id', 'tax_group_id', 'tax_rate_id',
17+
];
18+
19+
public function taxGroup(): BelongsTo
20+
{
21+
return $this->belongsTo(TaxGroup::class);
22+
}
23+
24+
public function taxRate(): BelongsTo
25+
{
26+
return $this->belongsTo(TaxRate::class);
27+
}
28+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
use Illuminate\Database\Eloquent\Relations\HasMany;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class TaxRate extends Model
12+
{
13+
use BelongsToTenant;
14+
use SoftDeletes;
15+
16+
protected $table = 'tax_rates';
17+
18+
protected $fillable = [
19+
'tenant_id', 'name', 'rate', 'tax_type',
20+
'is_compound', 'is_active', 'account_id',
21+
];
22+
23+
protected $casts = [
24+
'rate' => 'decimal:4',
25+
'is_compound' => 'boolean',
26+
'is_active' => 'boolean',
27+
];
28+
29+
public function account(): BelongsTo
30+
{
31+
return $this->belongsTo(Account::class);
32+
}
33+
34+
public function taxGroupItems(): HasMany
35+
{
36+
return $this->hasMany(TaxGroupItem::class);
37+
}
38+
39+
public function calculateTax(float $amount): float
40+
{
41+
return round($amount * $this->rate / 100, 4);
42+
}
43+
44+
public function scopeActive($query)
45+
{
46+
return $query->where('is_active', true);
47+
}
48+
49+
public function scopeForSales($query)
50+
{
51+
return $query->whereIn('tax_type', ['sales', 'both']);
52+
}
53+
54+
public function scopeForPurchase($query)
55+
{
56+
return $query->whereIn('tax_type', ['purchase', 'both']);
57+
}
58+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\TaxGroup;
7+
use App\Modules\Finance\Models\TaxGroupItem;
8+
use App\Modules\Finance\Models\TaxRate;
9+
10+
class TaxPolicy
11+
{
12+
public function viewAny(User $user): bool { return $user->can('finance.view'); }
13+
public function view(User $user, mixed $model): bool { return $user->can('finance.view'); }
14+
public function create(User $user, mixed $model = null): bool { return $user->can('finance.create'); }
15+
public function update(User $user, mixed $model): bool { return $user->can('finance.create'); }
16+
public function delete(User $user, mixed $model): bool { return $user->can('finance.delete'); }
17+
}

erp/app/Modules/Finance/Providers/FinanceServiceProvider.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@
5757
use App\Modules\Finance\Models\ReturnRequest;
5858
use App\Modules\Finance\Models\ReturnRequestItem;
5959
use App\Modules\Finance\Policies\ReturnRequestPolicy;
60+
use App\Modules\Finance\Models\TaxRate;
61+
use App\Modules\Finance\Models\TaxGroup;
62+
use App\Modules\Finance\Models\TaxGroupItem;
63+
use App\Modules\Finance\Policies\TaxPolicy;
6064
use Illuminate\Support\Facades\Gate;
6165
use Illuminate\Support\ServiceProvider;
6266

@@ -100,6 +104,10 @@ public function boot(): void
100104
Gate::policy(ReturnRequest::class, ReturnRequestPolicy::class);
101105
Gate::policy(ReturnRequestItem::class, ReturnRequestPolicy::class);
102106

107+
Gate::policy(TaxRate::class, TaxPolicy::class);
108+
Gate::policy(TaxGroup::class, TaxPolicy::class);
109+
Gate::policy(TaxGroupItem::class, TaxPolicy::class);
110+
103111
if ($this->app->runningInConsole()) {
104112
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
105113
}

erp/app/Modules/Finance/routes/finance.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
use App\Modules\Finance\Http\Controllers\CommissionRuleController;
3333
use App\Modules\Finance\Http\Controllers\ContractController;
3434
use App\Modules\Finance\Http\Controllers\ReturnRequestController;
35+
use App\Modules\Finance\Http\Controllers\TaxRateController;
36+
use App\Modules\Finance\Http\Controllers\TaxGroupController;
3537

3638
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
3739

@@ -239,5 +241,12 @@
239241
Route::post('return-requests/{returnRequest}/reject', [ReturnRequestController::class, 'reject'])->name('return-requests.reject');
240242
Route::post('return-requests/{returnRequest}/mark-refunded', [ReturnRequestController::class, 'markRefunded'])->name('return-requests.mark-refunded');
241243
Route::resource('return-requests', ReturnRequestController::class)->except(['edit', 'update']);
244+
// Tax Rates
245+
Route::resource('tax-rates', TaxRateController::class)->except(['edit', 'update']);
246+
247+
// Tax Groups — custom actions BEFORE resource
248+
Route::post('tax-groups/{taxGroup}/rates', [TaxGroupController::class, 'addRate'])->name('tax-groups.rates.add');
249+
Route::delete('tax-groups/{taxGroup}/rates/{item}', [TaxGroupController::class, 'removeRate'])->name('tax-groups.rates.remove');
250+
Route::resource('tax-groups', TaxGroupController::class)->except(['edit', 'update']);
242251

243252
});

0 commit comments

Comments
 (0)