Skip to content

Commit de1c779

Browse files
committed
feat: implement user preferred currency and exchange rate normalization
- Added `preferredCurrency` field to user model and updated user registration to require it. - Integrated ExchangeRate API for cross-currency transactions and normalized money formatting across the application. - Updated transaction forms to display amounts in the user's preferred currency. - Implemented backfill script for existing users to set preferred currency based on their first active account. - Enhanced reports and dashboards to reflect user-specific currency preferences. - Added unit tests for currency formatting and exchange rate fetching. - Updated documentation to include new environment variable `EXCHANGE_RATE_API_KEY`.
1 parent 6aa3235 commit de1c779

42 files changed

Lines changed: 1061 additions & 427 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

actions/reports.ts

Lines changed: 80 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { Types } from "mongoose";
22

3+
import {
4+
convertMoney,
5+
preferredCurrencyOrDefault,
6+
roundMoney,
7+
} from "@/lib/currency/rates";
38
import { connectToDatabase } from "@/lib/db/client";
49
import { withTenantFilter } from "@/lib/tenant/getTenant";
510
import { ReportFilterInput, reportFilterSchema } from "@/lib/validators/report";
@@ -80,6 +85,9 @@ export type ReportExportRow = {
8085
category: string;
8186
account: string;
8287
amount: number;
88+
nativeAmount?: number;
89+
nativeCurrency?: string;
90+
exchangeRate?: number;
8391
notes: string;
8492
};
8593

@@ -90,6 +98,7 @@ export type FinancialReport = {
9098
toDate?: string;
9199
};
92100
generatedAt: string;
101+
displayCurrency: string;
93102
summary: ReportSummary;
94103
monthly: ReportBucket[];
95104
categories: ReportCategory[];
@@ -101,7 +110,7 @@ export type FinancialReport = {
101110
const outflowTypes = ["expense", "transfer_fee", "loan_repayment"] as const;
102111

103112
function roundToCents(value: number) {
104-
return Number(value.toFixed(2));
113+
return roundMoney(value);
105114
}
106115

107116
function monthKey(date: Date) {
@@ -167,12 +176,14 @@ function finalizeBucket(bucket: ReportBucket): ReportBucket {
167176
export async function getFinancialReportForTenant(
168177
tenantId: string,
169178
input: ReportFilterInput = {},
179+
options: { displayCurrency?: string } = {},
170180
): Promise<FinancialReport> {
171181
if (!tenantId) {
172182
throw new Error("Unauthorized: tenantId is required");
173183
}
174184

175185
const filters = reportFilterSchema.parse(input);
186+
const displayCurrency = preferredCurrencyOrDefault(options.displayCurrency);
176187

177188
await connectToDatabase();
178189

@@ -221,6 +232,12 @@ export async function getFinancialReportForTenant(
221232
},
222233
]),
223234
);
235+
const accountCurrencyById = new Map(
236+
accounts.map((account) => [
237+
account._id.toString(),
238+
account.baseCurrency,
239+
]),
240+
);
224241
const monthlyByKey = new Map<string, ReportBucket>();
225242
const categoryByKey = new Map<string, ReportCategory>();
226243
const accountSummaryById = new Map<string, ReportAccountSummary>(
@@ -241,11 +258,24 @@ export async function getFinancialReportForTenant(
241258
const summary = emptyBucket("summary");
242259

243260
for (const transaction of transactions as ReportTransaction[]) {
244-
applyTransactionToBucket(summary, transaction);
261+
const accountCurrency =
262+
accountCurrencyById.get(transaction.accountId.toString()) ??
263+
displayCurrency;
264+
const converted = await convertMoney(
265+
transaction.amount,
266+
accountCurrency,
267+
displayCurrency,
268+
);
269+
const displayTransaction = {
270+
...transaction,
271+
amount: converted.amount,
272+
};
273+
274+
applyTransactionToBucket(summary, displayTransaction);
245275

246276
const key = monthKey(new Date(transaction.date));
247277
const bucket = monthlyByKey.get(key) ?? emptyBucket(key);
248-
applyTransactionToBucket(bucket, transaction);
278+
applyTransactionToBucket(bucket, displayTransaction);
249279
monthlyByKey.set(key, bucket);
250280

251281
const categoryKey = `${transaction.type}:${transaction.category}`;
@@ -255,7 +285,7 @@ export async function getFinancialReportForTenant(
255285
total: 0,
256286
count: 0,
257287
};
258-
category.total += transaction.amount;
288+
category.total += displayTransaction.amount;
259289
category.count += 1;
260290
categoryByKey.set(categoryKey, category);
261291

@@ -264,24 +294,47 @@ export async function getFinancialReportForTenant(
264294

265295
if (accountSummary) {
266296
if (transaction.type === "earning") {
267-
accountSummary.income += transaction.amount;
268-
accountSummary.netFlow += transaction.amount;
297+
accountSummary.income += displayTransaction.amount;
298+
accountSummary.netFlow += displayTransaction.amount;
269299
}
270300

271301
if (
272302
outflowTypes.includes(transaction.type as (typeof outflowTypes)[number])
273303
) {
274-
accountSummary.outflow += transaction.amount;
275-
accountSummary.netFlow -= transaction.amount;
304+
accountSummary.outflow += displayTransaction.amount;
305+
accountSummary.netFlow -= displayTransaction.amount;
276306
}
277307

278308
if (transaction.type === "transfer") {
279-
accountSummary.transferVolume += transaction.amount;
309+
accountSummary.transferVolume += displayTransaction.amount;
280310
}
281311
}
282312
}
283313

284314
const finalizedSummary = finalizeBucket(summary);
315+
const exportRows = await Promise.all(
316+
(transactions as ReportTransaction[]).map(async (transaction) => {
317+
const account = accountNameById.get(transaction.accountId.toString());
318+
const nativeCurrency = account?.baseCurrency ?? displayCurrency;
319+
const converted = await convertMoney(
320+
transaction.amount,
321+
nativeCurrency,
322+
displayCurrency,
323+
);
324+
325+
return {
326+
date: new Date(transaction.date).toISOString(),
327+
type: transaction.type,
328+
category: transaction.category,
329+
account: account?.name ?? transaction.accountId.toString(),
330+
amount: converted.amount,
331+
nativeAmount: transaction.amount,
332+
nativeCurrency,
333+
exchangeRate: converted.rate,
334+
notes: transaction.notes ?? "",
335+
};
336+
}),
337+
);
285338

286339
return {
287340
filters: {
@@ -290,6 +343,7 @@ export async function getFinancialReportForTenant(
290343
toDate: filters.toDate,
291344
},
292345
generatedAt: new Date().toISOString(),
346+
displayCurrency,
293347
summary: {
294348
income: finalizedSummary.income,
295349
expenses: finalizedSummary.expenses,
@@ -338,27 +392,30 @@ export async function getFinancialReportForTenant(
338392
isClosed: loan.isClosed,
339393
};
340394
}),
341-
exportRows: (transactions as ReportTransaction[]).map((transaction) => {
342-
const account = accountNameById.get(transaction.accountId.toString());
343-
344-
return {
345-
date: new Date(transaction.date).toISOString(),
346-
type: transaction.type,
347-
category: transaction.category,
348-
account: account?.name ?? transaction.accountId.toString(),
349-
amount: transaction.amount,
350-
notes: transaction.notes ?? "",
351-
};
352-
}),
395+
exportRows,
353396
};
354397
}
355398

356399
export function reportToCsv(report: FinancialReport): string {
357-
const headers = ["date", "type", "category", "account", "amount", "notes"];
400+
const headers = [
401+
"date",
402+
"type",
403+
"category",
404+
"account",
405+
"amount",
406+
"displayCurrency",
407+
"nativeAmount",
408+
"nativeCurrency",
409+
"exchangeRate",
410+
"notes",
411+
];
358412
const rows = report.exportRows.map((row) =>
359413
headers
360414
.map((header) => {
361-
const value = row[header as keyof ReportExportRow];
415+
const value =
416+
header === "displayCurrency"
417+
? report.displayCurrency
418+
: row[header as keyof ReportExportRow];
362419
const escaped = String(value).replaceAll('"', '""');
363420

364421
return `"${escaped}"`;

actions/transfers.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Types } from "mongoose";
22

3+
import { getExchangeRate } from "@/lib/currency/rates";
34
import { runInDbTransaction } from "@/lib/db/transaction";
45
import { writeAuditLog } from "@/lib/security/audit";
56
import { withTenantFilter } from "@/lib/tenant/getTenant";
@@ -52,13 +53,17 @@ export async function createTransferForTenant(
5253
const sameCurrency =
5354
sourceAccount.baseCurrency === destinationAccount.baseCurrency;
5455

55-
if (!sameCurrency && !payload.exchangeRate) {
56-
throw new Error("Exchange rate is required for cross-currency transfers");
57-
}
56+
const exchangeRate = sameCurrency
57+
? undefined
58+
: (payload.exchangeRate ??
59+
(await getExchangeRate(
60+
sourceAccount.baseCurrency,
61+
destinationAccount.baseCurrency,
62+
)));
5863

5964
const creditedAmount = sameCurrency
6065
? roundToCents(payload.amount)
61-
: roundToCents(payload.amount * Number(payload.exchangeRate));
66+
: roundToCents(payload.amount * Number(exchangeRate));
6267

6368
const debitedAmount = roundToCents(payload.amount + payload.fee);
6469

@@ -88,7 +93,7 @@ export async function createTransferForTenant(
8893
date,
8994
amount: roundToCents(payload.amount),
9095
category: "Transfer",
91-
exchangeRate: payload.exchangeRate,
96+
exchangeRate,
9297
notes: payload.notes || "Outgoing transfer",
9398
});
9499

@@ -101,7 +106,7 @@ export async function createTransferForTenant(
101106
date,
102107
amount: creditedAmount,
103108
category: "Transfer",
104-
exchangeRate: payload.exchangeRate,
109+
exchangeRate,
105110
notes: payload.notes || "Incoming transfer",
106111
});
107112

app/(admin)/admin/finance/page.tsx

Lines changed: 15 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import {
22
getAdminFinanceSummary,
33
getAdminFinanceTenantLeaderboard,
44
} from '@/actions/admin';
5+
import { formatMoney } from '@/lib/currency/rates';
6+
7+
const platformReportingCurrency = 'USD';
58

69
export default async function AdminFinancePage() {
710
const [summary, leaderboard] = await Promise.all([
@@ -16,7 +19,8 @@ export default async function AdminFinancePage() {
1619
Platform Finance
1720
</h1>
1821
<p className='text-sm text-slate-500 mt-1'>
19-
Global transaction metrics and tenant leaderboards
22+
Global transaction metrics and tenant leaderboards in USD reporting
23+
currency
2024
</p>
2125
</header>
2226

@@ -26,23 +30,15 @@ export default async function AdminFinancePage() {
2630
Total Income
2731
</p>
2832
<p className='text-3xl font-bold text-emerald-600'>
29-
$
30-
{summary.income.toLocaleString('en-US', {
31-
minimumFractionDigits: 2,
32-
maximumFractionDigits: 2,
33-
})}
33+
{formatMoney(summary.income, platformReportingCurrency)}
3434
</p>
3535
</div>
3636
<div className='card p-5'>
3737
<p className='text-xs font-medium text-slate-500 uppercase tracking-wider mb-2'>
3838
Total Expense
3939
</p>
4040
<p className='text-3xl font-bold text-rose-600'>
41-
$
42-
{summary.expense.toLocaleString('en-US', {
43-
minimumFractionDigits: 2,
44-
maximumFractionDigits: 2,
45-
})}
41+
{formatMoney(summary.expense, platformReportingCurrency)}
4642
</p>
4743
</div>
4844
<div className='card p-5'>
@@ -52,23 +48,16 @@ export default async function AdminFinancePage() {
5248
<p
5349
className={`text-3xl font-bold ${summary.netFlow >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}
5450
>
55-
{summary.netFlow >= 0 ? '+' : ''}$
56-
{summary.netFlow.toLocaleString('en-US', {
57-
minimumFractionDigits: 2,
58-
maximumFractionDigits: 2,
59-
})}
51+
{summary.netFlow >= 0 ? '+' : ''}
52+
{formatMoney(summary.netFlow, platformReportingCurrency)}
6053
</p>
6154
</div>
6255
<div className='card p-5'>
6356
<p className='text-xs font-medium text-slate-500 uppercase tracking-wider mb-2'>
6457
Transaction Vol
6558
</p>
6659
<p className='text-3xl font-bold text-slate-900'>
67-
$
68-
{summary.volume.toLocaleString('en-US', {
69-
minimumFractionDigits: 2,
70-
maximumFractionDigits: 2,
71-
})}
60+
{formatMoney(summary.volume, platformReportingCurrency)}
7261
</p>
7362
</div>
7463
</section>
@@ -113,38 +102,23 @@ export default async function AdminFinancePage() {
113102
{row.tenantId}
114103
</td>
115104
<td className='px-6 py-4 text-right text-emerald-600'>
116-
$
117-
{row.income.toLocaleString('en-US', {
118-
minimumFractionDigits: 2,
119-
maximumFractionDigits: 2,
120-
})}
105+
{formatMoney(row.income, platformReportingCurrency)}
121106
</td>
122107
<td className='px-6 py-4 text-right text-rose-600'>
123-
$
124-
{row.expense.toLocaleString('en-US', {
125-
minimumFractionDigits: 2,
126-
maximumFractionDigits: 2,
127-
})}
108+
{formatMoney(row.expense, platformReportingCurrency)}
128109
</td>
129110
<td className='px-6 py-4 text-right font-medium'>
130111
<span
131112
className={
132113
row.netFlow >= 0 ? 'text-emerald-600' : 'text-rose-600'
133114
}
134115
>
135-
{row.netFlow >= 0 ? '+' : ''}$
136-
{row.netFlow.toLocaleString('en-US', {
137-
minimumFractionDigits: 2,
138-
maximumFractionDigits: 2,
139-
})}
116+
{row.netFlow >= 0 ? '+' : ''}
117+
{formatMoney(row.netFlow, platformReportingCurrency)}
140118
</span>
141119
</td>
142120
<td className='px-6 py-4 text-right text-slate-600'>
143-
$
144-
{row.volume.toLocaleString('en-US', {
145-
minimumFractionDigits: 2,
146-
maximumFractionDigits: 2,
147-
})}
121+
{formatMoney(row.volume, platformReportingCurrency)}
148122
</td>
149123
</tr>
150124
))}

0 commit comments

Comments
 (0)