Skip to content

Commit 06e8036

Browse files
committed
feat(finance): Phase 85 — Multi-Currency Support with exchange rates
- Add currencies table and Currency model with BelongsToTenant, is_base/is_active casts, scopeActive/scopeBase, setAsBase(), getBase() methods - Add from_currency/to_currency/is_active columns to exchange_rates via ALTER TABLE migration - Update ExchangeRate model to support both old (base_currency/quote_currency) and new (from_currency/to_currency) column names via mutators; update getRate/convert signatures for backward compatibility - Add CurrencyController with index/store/update/destroy/setBase actions - Add CurrencyPolicy (finance.view/create/delete permissions) - Add ExchangeRateController convert action for currency conversion page - Register Currency routes (set-base before resource) and exchange-rates/convert route before resource - Register CurrencyPolicy in FinanceServiceProvider - Add Currency, CurrencyExchangeRate TypeScript types; update ExchangeRate interface with new fields - Add Currencies/Index.tsx page with inline create form, base currency badge, set-base action - Add ExchangeRates/Convert.tsx currency converter page - Update ExchangeRates/Index.tsx with inline create form and from/to currency display - Add Currencies sidebar link in Finance section - Add 10 tests in CurrencyTest.php (891 → 901) https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 1c8c85d commit 06e8036

15 files changed

Lines changed: 963 additions & 35 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\Currency;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class CurrencyController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$this->authorize('viewAny', Currency::class);
17+
18+
$currencies = Currency::withoutGlobalScopes()
19+
->where('tenant_id', app('tenant')->id)
20+
->orderBy('code')
21+
->get();
22+
23+
return Inertia::render('Finance/Currencies/Index', [
24+
'currencies' => $currencies,
25+
]);
26+
}
27+
28+
public function store(Request $request): RedirectResponse
29+
{
30+
$this->authorize('create', Currency::class);
31+
32+
$validated = $request->validate([
33+
'code' => ['required', 'string', 'size:3', 'regex:/^[A-Z]{3}$/'],
34+
'name' => ['required', 'string', 'max:100'],
35+
'symbol' => ['required', 'string', 'max:10'],
36+
'decimal_places' => ['sometimes', 'integer', 'min:0', 'max:4'],
37+
'is_base' => ['sometimes', 'boolean'],
38+
'is_active' => ['sometimes', 'boolean'],
39+
]);
40+
41+
$tenantId = app('tenant')->id;
42+
$validated['tenant_id'] = $tenantId;
43+
$validated['code'] = strtoupper($validated['code']);
44+
45+
$isBase = $validated['is_base'] ?? false;
46+
unset($validated['is_base']);
47+
48+
$currency = Currency::create($validated);
49+
50+
if ($isBase) {
51+
$currency->setAsBase();
52+
}
53+
54+
return redirect()->back()->with('success', 'Currency created.');
55+
}
56+
57+
public function update(Request $request, Currency $currency): RedirectResponse
58+
{
59+
$this->authorize('update', $currency);
60+
61+
$validated = $request->validate([
62+
'code' => ['required', 'string', 'size:3', 'regex:/^[A-Z]{3}$/'],
63+
'name' => ['required', 'string', 'max:100'],
64+
'symbol' => ['required', 'string', 'max:10'],
65+
'decimal_places' => ['sometimes', 'integer', 'min:0', 'max:4'],
66+
'is_base' => ['sometimes', 'boolean'],
67+
'is_active' => ['sometimes', 'boolean'],
68+
]);
69+
70+
$isBase = $validated['is_base'] ?? false;
71+
unset($validated['is_base']);
72+
73+
$currency->update($validated);
74+
75+
if ($isBase) {
76+
$currency->setAsBase();
77+
}
78+
79+
return redirect()->back()->with('success', 'Currency updated.');
80+
}
81+
82+
public function destroy(Currency $currency): RedirectResponse
83+
{
84+
$this->authorize('delete', $currency);
85+
86+
if ($currency->is_base) {
87+
abort(422, 'Cannot delete the base currency.');
88+
}
89+
90+
$currency->delete();
91+
92+
return redirect()->back()->with('success', 'Currency deleted.');
93+
}
94+
95+
public function setBase(Request $request, Currency $currency): RedirectResponse
96+
{
97+
$this->authorize('update', $currency);
98+
99+
$currency->setAsBase();
100+
101+
return redirect()->back()->with('success', 'Base currency updated.');
102+
}
103+
}

erp/app/Modules/Finance/Http/Controllers/ExchangeRateController.php

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
namespace App\Modules\Finance\Http\Controllers;
44

55
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\Currency;
67
use App\Modules\Finance\Models\ExchangeRate;
8+
use Carbon\Carbon;
79
use Illuminate\Http\RedirectResponse;
810
use Illuminate\Http\Request;
911
use Illuminate\Validation\Rule;
@@ -16,17 +18,35 @@ public function index(Request $request): Response
1618
{
1719
$this->authorize('viewAny', ExchangeRate::class);
1820

19-
$tenantId = $request->user()->tenant_id;
21+
$tenantId = app('tenant')->id;
2022

21-
$rates = ExchangeRate::withoutGlobalScopes()
23+
$query = ExchangeRate::withoutGlobalScopes()
2224
->where('tenant_id', $tenantId)
2325
->orderByDesc('effective_date')
2426
->orderBy('base_currency')
25-
->orderBy('quote_currency')
26-
->paginate(20);
27+
->orderBy('quote_currency');
28+
29+
if ($request->filled('from_currency')) {
30+
$from = $request->input('from_currency');
31+
$query->where(function ($q) use ($from) {
32+
$q->where('from_currency', $from)
33+
->orWhere(function ($q2) use ($from) {
34+
$q2->whereNull('from_currency')->where('base_currency', $from);
35+
});
36+
});
37+
}
38+
39+
$rates = $query->paginate(20);
40+
41+
$currencies = Currency::withoutGlobalScopes()
42+
->where('tenant_id', $tenantId)
43+
->active()
44+
->orderBy('code')
45+
->get();
2746

2847
return Inertia::render('Finance/ExchangeRates/Index', [
29-
'rates' => $rates,
48+
'rates' => $rates,
49+
'currencies' => $currencies,
3050
]);
3151
}
3252

@@ -44,15 +64,17 @@ public function store(Request $request): RedirectResponse
4464
$tenantId = app('tenant')->id;
4565

4666
$validated = $request->validate([
47-
'base_currency' => ['required', 'string', 'size:3'],
48-
'quote_currency' => ['required', 'string', 'size:3', 'different:base_currency'],
67+
'from_currency' => ['sometimes', 'nullable', 'string', 'size:3'],
68+
'to_currency' => ['sometimes', 'nullable', 'string', 'size:3'],
69+
'base_currency' => ['required_without:from_currency', 'nullable', 'string', 'size:3'],
70+
'quote_currency' => ['required_without:to_currency', 'nullable', 'string', 'size:3', 'different:base_currency'],
4971
'rate' => ['required', 'numeric', 'min:0.000001'],
5072
'effective_date' => [
5173
'required',
5274
'date',
5375
Rule::unique('exchange_rates')->where(fn ($q) => $q
54-
->where('base_currency', $request->input('base_currency'))
55-
->where('quote_currency', $request->input('quote_currency'))
76+
->where('base_currency', $request->input('base_currency') ?? $request->input('from_currency'))
77+
->where('quote_currency', $request->input('quote_currency') ?? $request->input('to_currency'))
5678
->where('tenant_id', $tenantId)
5779
->whereDate('effective_date', $request->input('effective_date'))
5880
),
@@ -62,8 +84,7 @@ public function store(Request $request): RedirectResponse
6284

6385
ExchangeRate::create(array_merge($validated, ['tenant_id' => $tenantId]));
6486

65-
return redirect()->route('finance.exchange-rates.index')
66-
->with('success', 'Exchange rate created.');
87+
return redirect()->back()->with('success', 'Exchange rate created.');
6788
}
6889

6990
public function destroy(ExchangeRate $exchangeRate): RedirectResponse
@@ -72,8 +93,45 @@ public function destroy(ExchangeRate $exchangeRate): RedirectResponse
7293

7394
$exchangeRate->delete();
7495

75-
return redirect()->route('finance.exchange-rates.index')
76-
->with('success', 'Exchange rate deleted.');
96+
return redirect()->back()->with('success', 'Exchange rate deleted.');
97+
}
98+
99+
public function convert(Request $request): Response
100+
{
101+
$this->authorize('viewAny', ExchangeRate::class);
102+
103+
$tenantId = app('tenant')->id;
104+
105+
$validated = $request->validate([
106+
'amount' => ['nullable', 'numeric', 'min:0'],
107+
'from' => ['nullable', 'string', 'size:3'],
108+
'to' => ['nullable', 'string', 'size:3'],
109+
'date' => ['nullable', 'date'],
110+
]);
111+
112+
$result = null;
113+
$rate = null;
114+
115+
if (!empty($validated['amount']) && !empty($validated['from']) && !empty($validated['to'])) {
116+
$date = !empty($validated['date']) ? Carbon::parse($validated['date']) : null;
117+
$rate = ExchangeRate::getRate($tenantId, $validated['from'], $validated['to'], $date);
118+
if ($rate !== null) {
119+
$result = ExchangeRate::convert($tenantId, (float) $validated['amount'], $validated['from'], $validated['to'], $date);
120+
}
121+
}
122+
123+
$currencies = Currency::withoutGlobalScopes()
124+
->where('tenant_id', $tenantId)
125+
->active()
126+
->orderBy('code')
127+
->get();
128+
129+
return Inertia::render('Finance/ExchangeRates/Convert', [
130+
'currencies' => $currencies,
131+
'result' => $result,
132+
'rate' => $rate,
133+
'input' => $validated,
134+
]);
77135
}
78136

79137
public function report(Request $request): Response
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
8+
class Currency extends Model
9+
{
10+
use BelongsToTenant;
11+
12+
protected $fillable = [
13+
'tenant_id',
14+
'code',
15+
'name',
16+
'symbol',
17+
'decimal_places',
18+
'is_base',
19+
'is_active',
20+
];
21+
22+
protected $casts = [
23+
'is_base' => 'boolean',
24+
'is_active' => 'boolean',
25+
];
26+
27+
public function scopeActive($query)
28+
{
29+
return $query->where('is_active', true);
30+
}
31+
32+
public function scopeBase($query)
33+
{
34+
return $query->where('is_base', true);
35+
}
36+
37+
public function setAsBase(): void
38+
{
39+
static::where('tenant_id', $this->tenant_id)->update(['is_base' => false]);
40+
$this->is_base = true;
41+
$this->save();
42+
}
43+
44+
public static function getBase(int $tenantId): ?self
45+
{
46+
return static::where('tenant_id', $tenantId)->where('is_base', true)->first();
47+
}
48+
}

0 commit comments

Comments
 (0)