Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
deb9aa8
feat(expense): wire FX gRPC client for foreign-currency expense creation
ItsThompson Aug 15, 2026
51b1a40
feat(finance): use period reporting currency for health score and add…
ItsThompson Aug 15, 2026
6e183ea
feat(frontend): reporting currency for history, dashboard widgets, an…
ItsThompson Aug 15, 2026
06184d7
feat(finance): record auth and app fallback in reporting currency mig…
ItsThompson Aug 15, 2026
18f8b58
feat(expense): flag legacy rows and partial snapshot fields for read …
ItsThompson Aug 15, 2026
731fd75
feat(expense): resolve legacy snapshots against period reporting curr…
ItsThompson Aug 15, 2026
d16af74
test(expense): cover legacy snapshot resolution across read paths
ItsThompson Aug 15, 2026
6c9ea2f
feat(expense): preserve transaction currency in expense suggestions
ItsThompson Aug 15, 2026
2210cb3
feat(frontend): autofill transaction currency from expense suggestions
ItsThompson Aug 15, 2026
d6018d9
feat(frontend): show mixed-currency expense log rows with reporting-a…
ItsThompson Aug 15, 2026
0ff448a
refactor: address review-9 should-fix items
ItsThompson Aug 15, 2026
02ee2c1
fix(expense): wire integrity telemetry and harden legacy resolution p…
ItsThompson Aug 15, 2026
54d7de5
chore: consolidate fx Dockerfile and workspace checksums from paralle…
ItsThompson Aug 15, 2026
bd28f3c
refactor(expense): harden FX wiring per review-7 should-fix items
ItsThompson Aug 15, 2026
cd5e048
fix(expense): preserve FX error categories in mapFxError
ItsThompson Aug 15, 2026
8289c98
fix(expense): log FX server failures distinctly and validate reportin…
ItsThompson Aug 15, 2026
de39858
fix(expense): check requireAPIError return value in test
ItsThompson Aug 15, 2026
43790ef
fix(expense): copy fx module in Dockerfile
ItsThompson Aug 15, 2026
d94c643
refactor(expense): drop legacy read-time synthesis
ItsThompson Aug 20, 2026
5eeafb3
fix(frontend): require expense snapshot fields
ItsThompson Aug 20, 2026
2286d96
docs: document FX service env vars in .env.example
ItsThompson Aug 20, 2026
5b916ef
feat(errkit): merge error-carried report data into reports
ItsThompson Aug 23, 2026
938e28d
refactor(expense): remove legacy amount/currency columns and enrich i…
ItsThompson Aug 23, 2026
9af3e63
fix(expense): correct stale backfill comments and fail fast on legacy…
ItsThompson Aug 23, 2026
c32fe42
refactor(finance): drop vestigial ExpenseData.Amount and flatten comp…
ItsThompson Aug 23, 2026
5b5b168
refactor(datarights): rename expense export columns to transaction_am…
ItsThompson Aug 23, 2026
fd35e18
refactor(frontend): remove legacy amount/currency and add expense-log…
ItsThompson Aug 23, 2026
41faeda
fix(frontend): restore period navigation in expense-log missing/error…
ItsThompson Aug 23, 2026
1e0afb8
test(expense): cover FX client-rejection branch and self-contain drop…
ItsThompson Aug 23, 2026
4e32511
test(e2e): assert suggestion transactionAmount after alias removal
ItsThompson Aug 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ ADMIN_PASSWORD=Admin1234!
IMMUDB_USERNAME=immudb
IMMUDB_PASSWORD=immudb

# --- FX Service -------------------------------------------------------------

# Open Exchange Rates app ID for live currency conversion. Required in
# production: without it, cross-currency conversions fail with a provider auth
# error. Same-currency conversions work without it. Get one at
# https://openexchangerates.org/signup
# Consumer: docker-compose → fx-service
OPEN_EXCHANGE_RATES_APP_ID=

# Provider request timeout, retry count, and cache max age. Defaults shown.
# Consumer: docker-compose → fx-service
FX_PROVIDER_TIMEOUT=2s
FX_PROVIDER_RETRY_COUNT=2
FX_CACHE_MAX_AGE=1h

# --- Datarights Service ------------------------------------------------------

# Email sending toggle and configuration
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ services:
- IMMUDB_USERNAME=${IMMUDB_USERNAME:-immudb}
- IMMUDB_PASSWORD=${IMMUDB_PASSWORD:-immudb}
- FINANCE_SERVICE_ADDR=finance-service:9083
- FX_SERVICE_ADDR=fx-service:9085
- LOG_LEVEL=${LOG_LEVEL:-info}
- ENVIRONMENT=${ENVIRONMENT:-development}
- SENTRY_DSN_BACKEND=${SENTRY_DSN_BACKEND:-}
Expand Down
4 changes: 2 additions & 2 deletions e2e/tests/expense-autocomplete.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,11 @@ test.describe("Expense autocomplete smoke", () => {
const body = await response.json();

const correctedSuggestion = body.data.find((suggestion: { name: string }) => suggestion.name === "Corrected Autocomplete");
expect(correctedSuggestion).toMatchObject({ amount: 3000, frequency: 1 });
expect(correctedSuggestion).toMatchObject({ transactionAmount: 3000, frequency: 1 });

const proRataSuggestion = body.data.find((suggestion: { name: string }) => suggestion.name === "Autocomplete Subscription");
expect(proRataSuggestion).toMatchObject({
amount: 10000,
transactionAmount: 10000,
frequency: 1,
expenseType: "essentials",
tagId: defaultTag.id,
Expand Down
6 changes: 6 additions & 0 deletions frontend/apps/finance/src/__tests__/monthly-trends.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const mockTrendData: TrendPoint[] = [
essentialsPercent: 50,
desiresPercent: 30,
savingsPercent: 20,
reportingCurrency: "USD",
},
{
year: 2026,
Expand All @@ -28,6 +29,7 @@ const mockTrendData: TrendPoint[] = [
essentialsPercent: 50,
desiresPercent: 30,
savingsPercent: 20,
reportingCurrency: "USD",
},
{
year: 2026,
Expand All @@ -40,6 +42,7 @@ const mockTrendData: TrendPoint[] = [
essentialsPercent: 50,
desiresPercent: 30,
savingsPercent: 20,
reportingCurrency: "USD",
},
{
year: 2026,
Expand All @@ -52,6 +55,7 @@ const mockTrendData: TrendPoint[] = [
essentialsPercent: 50,
desiresPercent: 30,
savingsPercent: 20,
reportingCurrency: "USD",
},
{
year: 2026,
Expand All @@ -64,6 +68,7 @@ const mockTrendData: TrendPoint[] = [
essentialsPercent: 50,
desiresPercent: 30,
savingsPercent: 20,
reportingCurrency: "USD",
},
{
year: 2026,
Expand All @@ -76,6 +81,7 @@ const mockTrendData: TrendPoint[] = [
essentialsPercent: 50,
desiresPercent: 30,
savingsPercent: 20,
reportingCurrency: "USD",
},
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ const mockFrecencyData: ExpenseFrecencyDataState = {
suggestions: [
{
name: "Groceries",
amount: 50000,
currency: "USD",
transactionAmount: 50000,
transactionCurrency: "USD",
expenseType: "essentials",
frequency: 114,
lastUsedAt: "2026-05-02T10:00:00Z",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ describe("DashboardFeature", () => {
comparison: {
currentSpent: 54500,
previousSpent: 0,
previousReportingCurrency: "",
comparable: true,
rollingAverage: null,
changePercent: 0,
},
Expand Down
16 changes: 10 additions & 6 deletions frontend/apps/finance/src/features/dashboard/__tests__/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ export const testCumulativeData = Array.from({ length: 31 }, (_, index) => ({
const testExpenseSuggestions = [
{
name: "Groceries",
amount: 50000,
currency: "USD",
transactionAmount: 50000,
transactionCurrency: "USD",
expenseType: "essentials" as const,
tagId: "tag-food",
frequency: 114,
Expand All @@ -79,8 +79,8 @@ const testExpenseSuggestions = [
},
{
name: "Coffee",
amount: 4500,
currency: "USD",
transactionAmount: 4500,
transactionCurrency: "USD",
expenseType: "desires" as const,
tagId: "tag-social",
frequency: 42,
Expand All @@ -95,7 +95,8 @@ export const testExpenses = [
id: "exp-1",
userId: "user-1",
name: "Groceries",
amount: 50000,
transactionAmount: 50000,
reportingAmount: 50000,
expenseType: "essentials",
tagId: "tag-food",
expenseDate: "2026-05-02",
Expand All @@ -107,7 +108,8 @@ export const testExpenses = [
id: "exp-2",
userId: "user-1",
name: "Coffee",
amount: 4500,
transactionAmount: 4500,
reportingAmount: 4500,
expenseType: "desires",
tagId: "tag-social",
expenseDate: "2026-05-01",
Expand Down Expand Up @@ -174,6 +176,8 @@ export function dashboardDataWithExpensesRoutes() {
comparison: {
currentSpent: 54500,
previousSpent: 48000,
previousReportingCurrency: "USD",
comparable: true,
rollingAverage: null,
changePercent: 13.54,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function buildHealthScore(overrides?: Partial<HealthScore>): HealthScore {
band: "amber",
provisional: false,
formulaVersion: 2,
reportingCurrency: "USD",
components: [
{ key: "savings_achievement", score: 20, max: 30, detail: "Saved $400 of $600 target" },
{ key: "budget_adherence", score: 30, max: 30, detail: "Spent $2,000 of $2,400 plan" },
Expand All @@ -45,9 +46,9 @@ function buildHealthScore(overrides?: Partial<HealthScore>): HealthScore {

function buildTrend(): HealthScoreTrendPoint[] {
return [
{ year: 2026, month: 3, total: 58, band: "amber", provisional: false, formulaVersion: 2 },
{ year: 2026, month: 4, total: 64, band: "amber", provisional: false, formulaVersion: 2 },
{ year: 2026, month: 5, total: 56, band: "amber", provisional: true, formulaVersion: 2 },
{ year: 2026, month: 3, total: 58, band: "amber", provisional: false, formulaVersion: 2, reportingCurrency: "USD" },
{ year: 2026, month: 4, total: 64, band: "amber", provisional: false, formulaVersion: 2, reportingCurrency: "USD" },
{ year: 2026, month: 5, total: 56, band: "amber", provisional: true, formulaVersion: 2, reportingCurrency: "USD" },
];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { useExpenseFrecencyData } from "../hooks/useExpenseFrecencyData";
const suggestions = [
{
name: "Groceries",
amount: 50000,
currency: "USD",
transactionAmount: 50000,
transactionCurrency: "USD",
expenseType: "essentials" as const,
tagId: "tag-food",
frequency: 114,
Expand All @@ -17,8 +17,8 @@ const suggestions = [
},
{
name: "Coffee",
amount: 4500,
currency: "USD",
transactionAmount: 4500,
transactionCurrency: "USD",
expenseType: "desires" as const,
tagId: "tag-social",
frequency: 42,
Expand All @@ -30,8 +30,8 @@ const suggestions = [

const olderSuggestion = {
name: "Old Bus Fare",
amount: 350,
currency: "USD",
transactionAmount: 350,
transactionCurrency: "USD",
expenseType: "essentials" as const,
tagId: "tag-transit",
frequency: 20,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ export function ExpenseFrecencyChart({
frequency: suggestion.frequency,
recencyBucket: suggestion.recencyBucket,
lastUsedAt: suggestion.lastUsedAt,
amount: suggestion.amount,
currency: suggestion.currency,
amount: suggestion.transactionAmount,
currency: suggestion.transactionCurrency,
expenseType: suggestion.expenseType,
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function HistoricalComparisonWidget({
}: HistoricalComparisonWidgetProps) {
const hasPrevious = comparison.previousSpent > 0 || comparison.currentSpent > 0;
const isOnlyOnePeriod = comparison.previousSpent === 0 && comparison.changePercent === 0;
const prevCurrency = comparison.previousReportingCurrency || currency;

return (
<Card data-testid="historical-comparison" className="h-full">
Expand All @@ -48,8 +49,13 @@ export function HistoricalComparisonWidget({
<div>
<p className="text-xs text-muted-foreground">Previous Period</p>
<p className="text-lg font-semibold">
{formatCurrency(comparison.previousSpent, currency)}
{formatCurrency(comparison.previousSpent, prevCurrency)}
</p>
{!comparison.comparable && (
<p className="text-xs text-muted-foreground" data-testid="not-comparable">
Different currency - not comparable
</p>
)}
</div>
<div>
<p className="text-xs text-muted-foreground">
Expand All @@ -60,7 +66,7 @@ export function HistoricalComparisonWidget({
{formatCurrency(comparison.rollingAverage, currency)}
</p>
) : null}
{hasPrevious && (
{hasPrevious && comparison.comparable && (
<div className="flex items-center gap-1 mt-1">
{comparison.changePercent > 0 ? (
<TrendingUp className="size-4 text-red-600 dark:text-red-400" />
Expand All @@ -87,4 +93,4 @@ export function HistoricalComparisonWidget({
</CardContent>
</Card>
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ interface RecentExpensesProps {
currency: string;
}

/**
* Recent expense list on the dashboard. Each row shows the budget-impact
* (reporting) amount formatted in the period reporting currency, not the
* transaction amount.
*/
export function RecentExpenses({ expenses, currency }: RecentExpensesProps) {
return (
<Card>
Expand All @@ -29,24 +34,27 @@ export function RecentExpenses({ expenses, currency }: RecentExpensesProps) {
</CardHeader>
<CardContent>
<div className="divide-y">
{expenses.map((expense) => (
<div
key={expense.id}
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{expense.name}</span>
<span className="text-xs text-muted-foreground">
{expense.expenseDate}
{expenses.map((expense) => {
const reportingAmount = expense.reportingAmount;
return (
<div
key={expense.id}
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{expense.name}</span>
<span className="text-xs text-muted-foreground">
{expense.expenseDate}
</span>
</div>
<span className="text-sm font-semibold">
{formatCurrency(reportingAmount, currency)}
</span>
</div>
<span className="text-sm font-semibold">
{formatCurrency(expense.amount, currency)}
</span>
</div>
))}
);
})}
</div>
</CardContent>
</Card>
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ const tags: Tag[] = [

const suggestion: ExpenseSuggestion = {
name: "Groceries",
amount: 1299,
currency: "USD",
transactionAmount: 1299,
transactionCurrency: "USD",
expenseType: "essentials",
tagId: "tag-food",
frequency: 7,
Expand Down Expand Up @@ -53,7 +53,7 @@ describe("createExpenseSuggestionPatch", () => {

it("formats zero-decimal suggestion amounts with their currency precision", () => {
expect(
createExpenseSuggestionPatch({ ...suggestion, amount: 1299, currency: "JPY" }, tags),
createExpenseSuggestionPatch({ ...suggestion, transactionAmount: 1299, transactionCurrency: "JPY" }, tags),
).toEqual({
name: "Groceries",
amountDollars: "1299",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ global.fetch = mockFetch;
function buildSuggestion(overrides: Partial<ExpenseSuggestion> = {}): ExpenseSuggestion {
return {
name: "Groceries",
amount: 7423,
currency: "USD",
transactionAmount: 7423,
transactionCurrency: "USD",
expenseType: "essentials",
tagId: "tag-groceries",
frequency: 12,
Expand Down Expand Up @@ -151,9 +151,9 @@ describe("useExpenseAutocomplete", () => {
});

it("dedupes candidates by exact name and keeps the first ranked record", async () => {
const firstGroceries = buildSuggestion({ name: "Groceries", amount: 1000, frecencyScore: 50 });
const duplicateGroceries = buildSuggestion({ name: "Groceries", amount: 2000, frecencyScore: 10 });
const coffee = buildSuggestion({ name: "Coffee", amount: 500, frecencyScore: 20 });
const firstGroceries = buildSuggestion({ name: "Groceries", transactionAmount: 1000, frecencyScore: 50 });
const duplicateGroceries = buildSuggestion({ name: "Groceries", transactionAmount: 2000, frecencyScore: 10 });
const coffee = buildSuggestion({ name: "Coffee", transactionAmount: 500, frecencyScore: 20 });
mockApiResponse(buildResponse([firstGroceries, duplicateGroceries, coffee]));

const { result } = renderHook(() => useExpenseAutocomplete());
Expand All @@ -166,9 +166,9 @@ describe("useExpenseAutocomplete", () => {
});

it("loads the next page and appends deduped candidates", async () => {
const groceries = buildSuggestion({ name: "Groceries", amount: 1000, frecencyScore: 50 });
const duplicateGroceries = buildSuggestion({ name: "Groceries", amount: 2000, frecencyScore: 10 });
const coffee = buildSuggestion({ name: "Coffee", amount: 500, frecencyScore: 20 });
const groceries = buildSuggestion({ name: "Groceries", transactionAmount: 1000, frecencyScore: 50 });
const duplicateGroceries = buildSuggestion({ name: "Groceries", transactionAmount: 2000, frecencyScore: 10 });
const coffee = buildSuggestion({ name: "Coffee", transactionAmount: 500, frecencyScore: 20 });
mockApiResponse(buildResponse([groceries], { hasMore: true }));
mockApiResponse(buildResponse([duplicateGroceries, coffee], { page: 2, hasMore: false }));

Expand Down
Loading
Loading