Skip to content

Commit 5d42784

Browse files
committed
feat(finance): Phase 54 — Multi-Currency Exchange Rate Management
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent dfd2ae5 commit 5d42784

10 files changed

Lines changed: 589 additions & 221 deletions

File tree

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

Lines changed: 92 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,59 +4,117 @@
44

55
use App\Http\Controllers\Controller;
66
use App\Modules\Finance\Models\ExchangeRate;
7+
use Illuminate\Http\RedirectResponse;
78
use Illuminate\Http\Request;
9+
use Illuminate\Validation\Rule;
810
use Inertia\Inertia;
11+
use Inertia\Response;
912

1013
class ExchangeRateController extends Controller
1114
{
12-
public function index(Request $request)
15+
public function index(Request $request): Response
1316
{
1417
$this->authorize('viewAny', ExchangeRate::class);
18+
1519
$tenantId = $request->user()->tenant_id;
16-
$rates = ExchangeRate::where('tenant_id', $tenantId)
17-
->orderByDesc('date')
18-
->orderBy('currency_code')
19-
->paginate(50);
20-
return Inertia::render('Finance/ExchangeRates/Index', ['rates' => $rates]);
20+
21+
$rates = ExchangeRate::withoutGlobalScopes()
22+
->where('tenant_id', $tenantId)
23+
->orderByDesc('effective_date')
24+
->orderBy('base_currency')
25+
->orderBy('quote_currency')
26+
->paginate(20);
27+
28+
return Inertia::render('Finance/ExchangeRates/Index', [
29+
'rates' => $rates,
30+
]);
2131
}
2232

23-
public function store(Request $request)
33+
public function create(): Response
2434
{
2535
$this->authorize('create', ExchangeRate::class);
26-
$data = $request->validate([
27-
'currency_code' => 'required|string|size:3',
28-
'rate' => 'required|numeric|min:0.000001',
29-
'date' => 'required|date',
36+
37+
return Inertia::render('Finance/ExchangeRates/Create');
38+
}
39+
40+
public function store(Request $request): RedirectResponse
41+
{
42+
$this->authorize('create', ExchangeRate::class);
43+
44+
$tenantId = app('tenant')->id;
45+
46+
$validated = $request->validate([
47+
'base_currency' => ['required', 'string', 'size:3'],
48+
'quote_currency' => ['required', 'string', 'size:3', 'different:base_currency'],
49+
'rate' => ['required', 'numeric', 'min:0.000001'],
50+
'effective_date' => [
51+
'required',
52+
'date',
53+
Rule::unique('exchange_rates')->where(fn ($q) => $q
54+
->where('base_currency', $request->input('base_currency'))
55+
->where('quote_currency', $request->input('quote_currency'))
56+
->where('tenant_id', $tenantId)
57+
->whereDate('effective_date', $request->input('effective_date'))
58+
),
59+
],
60+
'source' => ['nullable', 'string', 'max:100'],
3061
]);
31-
$tenantId = $request->user()->tenant_id;
32-
$currencyCode = strtoupper($data['currency_code']);
33-
$date = $data['date'];
3462

35-
$existing = ExchangeRate::withoutGlobalScopes()
36-
->where('tenant_id', $tenantId)
37-
->where('currency_code', $currencyCode)
38-
->whereDate('date', $date)
39-
->first();
40-
41-
if ($existing) {
42-
$existing->rate = $data['rate'];
43-
$existing->save();
44-
} else {
45-
ExchangeRate::create([
46-
'tenant_id' => $tenantId,
47-
'currency_code' => $currencyCode,
48-
'date' => $date,
49-
'rate' => $data['rate'],
50-
]);
51-
}
63+
ExchangeRate::create(array_merge($validated, ['tenant_id' => $tenantId]));
5264

53-
return back()->with('success', 'Exchange rate saved.');
65+
return redirect()->route('finance.exchange-rates.index')
66+
->with('success', 'Exchange rate created.');
5467
}
5568

56-
public function destroy(ExchangeRate $exchangeRate)
69+
public function destroy(ExchangeRate $exchangeRate): RedirectResponse
5770
{
5871
$this->authorize('delete', $exchangeRate);
72+
5973
$exchangeRate->delete();
60-
return back()->with('success', 'Exchange rate deleted.');
74+
75+
return redirect()->route('finance.exchange-rates.index')
76+
->with('success', 'Exchange rate deleted.');
77+
}
78+
79+
public function report(Request $request): Response
80+
{
81+
$this->authorize('viewAny', ExchangeRate::class);
82+
83+
$tenantId = $request->user()->tenant_id;
84+
$today = now()->toDateString();
85+
$ago30 = now()->subDays(30)->toDateString();
86+
87+
// Get all unique currency pairs for this tenant
88+
$pairs = ExchangeRate::withoutGlobalScopes()
89+
->where('tenant_id', $tenantId)
90+
->select('base_currency', 'quote_currency')
91+
->distinct()
92+
->get();
93+
94+
$rows = [];
95+
foreach ($pairs as $pair) {
96+
$currentRate = ExchangeRate::getRate($tenantId, $pair->base_currency, $pair->quote_currency, $today);
97+
$priorRate = ExchangeRate::getRate($tenantId, $pair->base_currency, $pair->quote_currency, $ago30);
98+
99+
$change = null;
100+
if ($priorRate !== null && $priorRate != 0 && $currentRate !== null) {
101+
$change = round((($currentRate - $priorRate) / $priorRate) * 100, 2);
102+
}
103+
104+
$rows[] = [
105+
'pair' => $pair->base_currency . '/' . $pair->quote_currency,
106+
'base_currency' => $pair->base_currency,
107+
'quote_currency' => $pair->quote_currency,
108+
'current_rate' => $currentRate,
109+
'prior_rate' => $priorRate,
110+
'change_pct' => $change,
111+
];
112+
}
113+
114+
return Inertia::render('Finance/ExchangeRates/Report', [
115+
'rows' => $rows,
116+
'asOf' => $today,
117+
'ago30' => $ago30,
118+
]);
61119
}
62120
}

erp/app/Modules/Finance/Models/ExchangeRate.php

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,32 +9,100 @@ class ExchangeRate extends Model
99
{
1010
use BelongsToTenant;
1111

12-
protected $fillable = ['tenant_id', 'currency_code', 'rate', 'date'];
13-
protected $casts = ['date' => 'date', 'rate' => 'float'];
12+
protected $fillable = [
13+
'tenant_id',
14+
'base_currency',
15+
'quote_currency',
16+
'rate',
17+
'effective_date',
18+
'source',
19+
];
20+
21+
protected $casts = [
22+
'effective_date' => 'date',
23+
'rate' => 'decimal:6',
24+
];
25+
26+
/**
27+
* Get the most recent rate on or before $date where base=from AND quote=to.
28+
* Returns the rate as float, or null if not found.
29+
*/
30+
public static function getRate(int $tenantId, string $from, string $to, ?string $date = null): ?float
31+
{
32+
$date = $date ?? now()->toDateString();
33+
34+
$value = static::withoutGlobalScopes()
35+
->where('tenant_id', $tenantId)
36+
->where('base_currency', $from)
37+
->where('quote_currency', $to)
38+
->where('effective_date', '<=', $date)
39+
->orderByDesc('effective_date')
40+
->value('rate');
41+
42+
return $value !== null ? (float) $value : null;
43+
}
44+
45+
/**
46+
* Convert an amount from one currency to another.
47+
* Returns null if no rate found.
48+
*/
49+
public static function convert(float $amount, string $from, string $to, int $tenantId, ?string $date = null): ?float
50+
{
51+
if ($from === $to) {
52+
return $amount;
53+
}
54+
55+
$rate = static::getRate($tenantId, $from, $to, $date);
56+
57+
if ($rate === null) {
58+
return null;
59+
}
60+
61+
return round($amount * $rate, 2);
62+
}
1463

1564
/**
16-
* Get the most recent rate for a currency on or before a given date.
17-
* Returns 1.0 if currency is USD (base) or no rate found.
65+
* Legacy helper: get rate for a currency code vs USD.
66+
* Returns 1.0 if currency is USD or no rate found.
1867
*/
1968
public static function rateFor(string $currencyCode, string $tenantIdOrDate, ?string $date = null): float
2069
{
21-
if ($currencyCode === 'USD') return 1.0;
70+
if ($currencyCode === 'USD') {
71+
return 1.0;
72+
}
2273

23-
// Support both: rateFor('EUR', $tenantId, $date) and rateFor('EUR', $date) with tenant from app()
2474
if ($date === null) {
2575
$date = $tenantIdOrDate;
2676
$tenantId = app('tenant')->id;
2777
} else {
28-
$tenantId = $tenantIdOrDate;
78+
$tenantId = (int) $tenantIdOrDate;
2979
}
3080

3181
$record = static::withoutGlobalScopes()
3282
->where('tenant_id', $tenantId)
33-
->where('currency_code', $currencyCode)
34-
->whereDate('date', '<=', $date)
35-
->orderByDesc('date')
83+
->where(function ($q) use ($currencyCode) {
84+
$q->where(function ($q2) use ($currencyCode) {
85+
$q2->where('base_currency', 'USD')
86+
->where('quote_currency', $currencyCode);
87+
})->orWhere(function ($q2) use ($currencyCode) {
88+
$q2->where('base_currency', $currencyCode)
89+
->where('quote_currency', 'USD');
90+
});
91+
})
92+
->whereDate('effective_date', '<=', $date)
93+
->orderByDesc('effective_date')
3694
->first();
3795

38-
return $record ? (float) $record->rate : 1.0;
96+
if (!$record) {
97+
return 1.0;
98+
}
99+
100+
// If base is USD, quote is the currency: rate = quote per USD
101+
if ($record->base_currency === 'USD') {
102+
return (float) $record->rate;
103+
}
104+
105+
// If base is currency and quote is USD: inverse
106+
return $record->rate != 0 ? round(1 / (float) $record->rate, 6) : 1.0;
39107
}
40108
}

erp/app/Modules/Finance/Policies/ExchangeRatePolicy.php

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,23 @@
77

88
class ExchangeRatePolicy
99
{
10-
public function viewAny(User $user): bool { return $user->can('finance.view'); }
11-
public function view(User $user, ExchangeRate $exchangeRate): bool { return $user->can('finance.view'); }
12-
public function create(User $user): bool { return $user->can('finance.create'); }
13-
public function update(User $user, ExchangeRate $exchangeRate): bool { return $user->can('finance.create'); }
14-
public function delete(User $user, ExchangeRate $exchangeRate): bool { return $user->can('finance.delete'); }
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->hasPermissionTo('finance.view');
13+
}
14+
15+
public function view(User $user, ExchangeRate $model): bool
16+
{
17+
return $user->hasPermissionTo('finance.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->hasPermissionTo('finance.create');
23+
}
24+
25+
public function delete(User $user, ExchangeRate $model): bool
26+
{
27+
return $user->hasPermissionTo('finance.delete');
28+
}
1529
}

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,9 @@
133133
Route::get('reports/comparative-profit-loss/export', [ReportController::class, 'exportComparativeProfitLoss'])->name('reports.comparative-profit-loss.export');
134134
Route::get('reports/cash-flow-forecast/export', [ReportController::class, 'exportCashFlowForecast'])->name('reports.cash-flow-forecast.export');
135135

136-
// Exchange Rates
137-
Route::get('/exchange-rates', [ExchangeRateController::class, 'index'])->name('exchange-rates.index');
138-
Route::post('/exchange-rates', [ExchangeRateController::class, 'store'])->name('exchange-rates.store');
139-
Route::delete('/exchange-rates/{exchangeRate}', [ExchangeRateController::class, 'destroy'])->name('exchange-rates.destroy');
136+
// Exchange Rates — report must come before resource to avoid 'report' being treated as an ID
137+
Route::get('exchange-rates/report', [ExchangeRateController::class, 'report'])->name('exchange-rates.report');
138+
Route::resource('exchange-rates', ExchangeRateController::class)->only(['index', 'create', 'store', 'destroy']);
140139

141140
// Budgets
142141
Route::post('budgets/{budget}/activate', [BudgetController::class, 'activate'])->name('budgets.activate');
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::table('exchange_rates', function (Blueprint $table) {
12+
$table->dropUnique(['tenant_id', 'currency_code', 'date']);
13+
$table->dropColumn(['currency_code', 'date']);
14+
});
15+
16+
Schema::table('exchange_rates', function (Blueprint $table) {
17+
$table->string('base_currency', 3)->after('tenant_id');
18+
$table->string('quote_currency', 3)->after('base_currency');
19+
$table->date('effective_date')->after('rate');
20+
$table->string('source', 100)->nullable()->after('effective_date');
21+
$table->unique(
22+
['tenant_id', 'base_currency', 'quote_currency', 'effective_date'],
23+
'exchange_rates_unique_pair_date'
24+
);
25+
});
26+
}
27+
28+
public function down(): void
29+
{
30+
Schema::table('exchange_rates', function (Blueprint $table) {
31+
$table->dropUnique('exchange_rates_unique_pair_date');
32+
$table->dropColumn(['base_currency', 'quote_currency', 'effective_date', 'source']);
33+
});
34+
35+
Schema::table('exchange_rates', function (Blueprint $table) {
36+
$table->string('currency_code', 3)->after('tenant_id');
37+
$table->date('date')->after('rate');
38+
$table->unique(['tenant_id', 'currency_code', 'date']);
39+
});
40+
}
41+
};

0 commit comments

Comments
 (0)