diff --git a/.env.example b/.env.example index b8e861bd..ceddcd25 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 2402a110..21f8eeb1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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:-} diff --git a/e2e/tests/expense-autocomplete.spec.ts b/e2e/tests/expense-autocomplete.spec.ts index 68ff4400..4904048e 100644 --- a/e2e/tests/expense-autocomplete.spec.ts +++ b/e2e/tests/expense-autocomplete.spec.ts @@ -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, diff --git a/frontend/apps/finance/src/__tests__/monthly-trends.test.tsx b/frontend/apps/finance/src/__tests__/monthly-trends.test.tsx index f8f3e12e..d4a06ff5 100644 --- a/frontend/apps/finance/src/__tests__/monthly-trends.test.tsx +++ b/frontend/apps/finance/src/__tests__/monthly-trends.test.tsx @@ -16,6 +16,7 @@ const mockTrendData: TrendPoint[] = [ essentialsPercent: 50, desiresPercent: 30, savingsPercent: 20, + reportingCurrency: "USD", }, { year: 2026, @@ -28,6 +29,7 @@ const mockTrendData: TrendPoint[] = [ essentialsPercent: 50, desiresPercent: 30, savingsPercent: 20, + reportingCurrency: "USD", }, { year: 2026, @@ -40,6 +42,7 @@ const mockTrendData: TrendPoint[] = [ essentialsPercent: 50, desiresPercent: 30, savingsPercent: 20, + reportingCurrency: "USD", }, { year: 2026, @@ -52,6 +55,7 @@ const mockTrendData: TrendPoint[] = [ essentialsPercent: 50, desiresPercent: 30, savingsPercent: 20, + reportingCurrency: "USD", }, { year: 2026, @@ -64,6 +68,7 @@ const mockTrendData: TrendPoint[] = [ essentialsPercent: 50, desiresPercent: 30, savingsPercent: 20, + reportingCurrency: "USD", }, { year: 2026, @@ -76,6 +81,7 @@ const mockTrendData: TrendPoint[] = [ essentialsPercent: 50, desiresPercent: 30, savingsPercent: 20, + reportingCurrency: "USD", }, ]; diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/BreakdownSection.test.tsx b/frontend/apps/finance/src/features/dashboard/__tests__/BreakdownSection.test.tsx index d210709d..76dc77db 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/BreakdownSection.test.tsx +++ b/frontend/apps/finance/src/features/dashboard/__tests__/BreakdownSection.test.tsx @@ -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", diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature-historical.test.tsx b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature-historical.test.tsx index 1cf00859..2f1cff4c 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature-historical.test.tsx +++ b/frontend/apps/finance/src/features/dashboard/__tests__/DashboardFeature-historical.test.tsx @@ -111,6 +111,8 @@ describe("DashboardFeature", () => { comparison: { currentSpent: 54500, previousSpent: 0, + previousReportingCurrency: "", + comparable: true, rollingAverage: null, changePercent: 0, }, diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/fixtures.ts b/frontend/apps/finance/src/features/dashboard/__tests__/fixtures.ts index 09186d02..0cdcb15c 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/fixtures.ts +++ b/frontend/apps/finance/src/features/dashboard/__tests__/fixtures.ts @@ -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, @@ -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, @@ -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", @@ -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", @@ -174,6 +176,8 @@ export function dashboardDataWithExpensesRoutes() { comparison: { currentSpent: 54500, previousSpent: 48000, + previousReportingCurrency: "USD", + comparable: true, rollingAverage: null, changePercent: 13.54, }, diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/health-score-card.test.tsx b/frontend/apps/finance/src/features/dashboard/__tests__/health-score-card.test.tsx index 80e9e370..de1290a2 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/health-score-card.test.tsx +++ b/frontend/apps/finance/src/features/dashboard/__tests__/health-score-card.test.tsx @@ -28,6 +28,7 @@ function buildHealthScore(overrides?: Partial): 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" }, @@ -45,9 +46,9 @@ function buildHealthScore(overrides?: Partial): 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" }, ]; } diff --git a/frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts b/frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts index 63e90d3f..850c5f82 100644 --- a/frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts +++ b/frontend/apps/finance/src/features/dashboard/__tests__/useExpenseFrecencyData.test.ts @@ -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, @@ -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, @@ -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, diff --git a/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx b/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx index f011ca25..aabdfe9d 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/ExpenseFrecencyChart.tsx @@ -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, })); diff --git a/frontend/apps/finance/src/features/dashboard/components/widgets/HistoricalComparisonWidget.tsx b/frontend/apps/finance/src/features/dashboard/components/widgets/HistoricalComparisonWidget.tsx index 6650f86f..748b0106 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/HistoricalComparisonWidget.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/HistoricalComparisonWidget.tsx @@ -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 ( @@ -48,8 +49,13 @@ export function HistoricalComparisonWidget({

Previous Period

- {formatCurrency(comparison.previousSpent, currency)} + {formatCurrency(comparison.previousSpent, prevCurrency)}

+ {!comparison.comparable && ( +

+ Different currency - not comparable +

+ )}

@@ -60,7 +66,7 @@ export function HistoricalComparisonWidget({ {formatCurrency(comparison.rollingAverage, currency)}

) : null} - {hasPrevious && ( + {hasPrevious && comparison.comparable && (
{comparison.changePercent > 0 ? ( @@ -87,4 +93,4 @@ export function HistoricalComparisonWidget({ ); -} +} \ No newline at end of file diff --git a/frontend/apps/finance/src/features/dashboard/components/widgets/RecentExpenses.tsx b/frontend/apps/finance/src/features/dashboard/components/widgets/RecentExpenses.tsx index 217f726c..e4794ec4 100644 --- a/frontend/apps/finance/src/features/dashboard/components/widgets/RecentExpenses.tsx +++ b/frontend/apps/finance/src/features/dashboard/components/widgets/RecentExpenses.tsx @@ -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 ( @@ -29,24 +34,27 @@ export function RecentExpenses({ expenses, currency }: RecentExpensesProps) {
- {expenses.map((expense) => ( -
-
- {expense.name} - - {expense.expenseDate} + {expenses.map((expense) => { + const reportingAmount = expense.reportingAmount; + return ( +
+
+ {expense.name} + + {expense.expenseDate} + +
+ + {formatCurrency(reportingAmount, currency)}
- - {formatCurrency(expense.amount, currency)} - -
- ))} + ); + })}
); -} +} \ No newline at end of file diff --git a/frontend/apps/finance/src/features/expense-autocomplete/__tests__/suggestionAutofill.test.ts b/frontend/apps/finance/src/features/expense-autocomplete/__tests__/suggestionAutofill.test.ts index 3a4dd7cf..c8bc229b 100644 --- a/frontend/apps/finance/src/features/expense-autocomplete/__tests__/suggestionAutofill.test.ts +++ b/frontend/apps/finance/src/features/expense-autocomplete/__tests__/suggestionAutofill.test.ts @@ -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, @@ -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", diff --git a/frontend/apps/finance/src/features/expense-autocomplete/__tests__/useExpenseAutocomplete.test.ts b/frontend/apps/finance/src/features/expense-autocomplete/__tests__/useExpenseAutocomplete.test.ts index c1a4500f..d71a9e68 100644 --- a/frontend/apps/finance/src/features/expense-autocomplete/__tests__/useExpenseAutocomplete.test.ts +++ b/frontend/apps/finance/src/features/expense-autocomplete/__tests__/useExpenseAutocomplete.test.ts @@ -9,8 +9,8 @@ global.fetch = mockFetch; function buildSuggestion(overrides: Partial = {}): ExpenseSuggestion { return { name: "Groceries", - amount: 7423, - currency: "USD", + transactionAmount: 7423, + transactionCurrency: "USD", expenseType: "essentials", tagId: "tag-groceries", frequency: 12, @@ -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()); @@ -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 })); diff --git a/frontend/apps/finance/src/features/expense-autocomplete/components/ExpenseNameCombobox.tsx b/frontend/apps/finance/src/features/expense-autocomplete/components/ExpenseNameCombobox.tsx index ee56d3b8..1335e199 100644 --- a/frontend/apps/finance/src/features/expense-autocomplete/components/ExpenseNameCombobox.tsx +++ b/frontend/apps/finance/src/features/expense-autocomplete/components/ExpenseNameCombobox.tsx @@ -9,10 +9,21 @@ import { ComboboxList, } from "@gofin/ui/components/combobox"; import { FormMessage } from "@gofin/ui/components/form"; +import { getCurrencySymbol, toMajorUnits, getMinorUnitDigits } from "@gofin/core"; import { useExpenseAutocomplete } from "../hooks/useExpenseAutocomplete"; import type { ExpenseSuggestion } from "../types"; +function formatSuggestionAmount(suggestion: ExpenseSuggestion): string { + const major = toMajorUnits( + suggestion.transactionAmount, + suggestion.transactionCurrency, + ); + const symbol = getCurrencySymbol(suggestion.transactionCurrency); + const digits = getMinorUnitDigits(suggestion.transactionCurrency); + return `${symbol}${major.toFixed(digits)}`; +} + export interface ExpenseNameComboboxProps { id?: string; value: string; @@ -74,7 +85,7 @@ export function ExpenseNameCombobox({
{suggestion.name} - Frecency score: {suggestion.frecencyScore} + {formatSuggestionAmount(suggestion)} · {suggestion.transactionCurrency} · Frecency: {suggestion.frecencyScore}
diff --git a/frontend/apps/finance/src/features/expense-autocomplete/suggestionAutofill.ts b/frontend/apps/finance/src/features/expense-autocomplete/suggestionAutofill.ts index 3d11139e..8223bd29 100644 --- a/frontend/apps/finance/src/features/expense-autocomplete/suggestionAutofill.ts +++ b/frontend/apps/finance/src/features/expense-autocomplete/suggestionAutofill.ts @@ -14,8 +14,11 @@ export function createExpenseSuggestionPatch( return { name: suggestion.name, - amountDollars: formatMinorUnits(suggestion.amount, suggestion.currency), - currency: suggestion.currency, + amountDollars: formatMinorUnits( + suggestion.transactionAmount, + suggestion.transactionCurrency, + ), + currency: suggestion.transactionCurrency, expenseType: suggestion.expenseType, tagId: hasValidTag ? suggestion.tagId : null, }; diff --git a/frontend/apps/finance/src/features/expense-autocomplete/types.ts b/frontend/apps/finance/src/features/expense-autocomplete/types.ts index 93b29418..b62f4316 100644 --- a/frontend/apps/finance/src/features/expense-autocomplete/types.ts +++ b/frontend/apps/finance/src/features/expense-autocomplete/types.ts @@ -10,8 +10,8 @@ export interface ExpenseSuggestionPatch { export interface ExpenseSuggestion { name: string; - amount: number; - currency: string; + transactionAmount: number; + transactionCurrency: string; expenseType: ExpenseType; tagId: string; frequency: number; diff --git a/frontend/apps/finance/src/features/expense-detail/__tests__/computeChanges.test.ts b/frontend/apps/finance/src/features/expense-detail/__tests__/computeChanges.test.ts index 59dd8ad4..7daefcd9 100644 --- a/frontend/apps/finance/src/features/expense-detail/__tests__/computeChanges.test.ts +++ b/frontend/apps/finance/src/features/expense-detail/__tests__/computeChanges.test.ts @@ -11,8 +11,10 @@ const baseExpense: Expense = { id: "exp-1", userId: "user-1", name: "Groceries", - amount: 5000, transactionCurrency: "USD", + transactionAmount: 5000, + reportingAmount: 5000, + reportingCurrency: "USD", expenseType: "essentials", tagId: "tag-food", expenseDate: "2026-05-02", @@ -27,7 +29,7 @@ describe("computeChanges", () => { it("returns empty array when nothing changed", () => { const corrected = { name: "Groceries", - amount: 5000, + transactionAmount: 5000, expenseType: "essentials" as const, tagId: "tag-food", expenseDate: "2026-05-02", @@ -40,7 +42,7 @@ describe("computeChanges", () => { it("detects name change", () => { const corrected = { name: "Weekly Groceries", - amount: 5000, + transactionAmount: 5000, expenseType: "essentials" as const, tagId: "tag-food", expenseDate: "2026-05-02", @@ -55,7 +57,7 @@ describe("computeChanges", () => { it("detects amount change with formatted currency diff", () => { const corrected = { name: "Groceries", - amount: 7500, + transactionAmount: 7500, expenseType: "essentials" as const, tagId: "tag-food", expenseDate: "2026-05-02", @@ -70,7 +72,7 @@ describe("computeChanges", () => { it("detects expense type change", () => { const corrected = { name: "Groceries", - amount: 5000, + transactionAmount: 5000, expenseType: "desires" as const, tagId: "tag-food", expenseDate: "2026-05-02", @@ -85,7 +87,7 @@ describe("computeChanges", () => { it("detects tag change and resolves tag names", () => { const corrected = { name: "Groceries", - amount: 5000, + transactionAmount: 5000, expenseType: "essentials" as const, tagId: "tag-transport", expenseDate: "2026-05-02", @@ -100,7 +102,7 @@ describe("computeChanges", () => { it("falls back to tag ID when tag name is not found", () => { const corrected = { name: "Groceries", - amount: 5000, + transactionAmount: 5000, expenseType: "essentials" as const, tagId: "tag-unknown", expenseDate: "2026-05-02", @@ -115,7 +117,7 @@ describe("computeChanges", () => { it("detects date change", () => { const corrected = { name: "Groceries", - amount: 5000, + transactionAmount: 5000, expenseType: "essentials" as const, tagId: "tag-food", expenseDate: "2026-05-10", @@ -130,7 +132,7 @@ describe("computeChanges", () => { it("detects multiple changes at once", () => { const corrected = { name: "Updated Groceries", - amount: 7500, + transactionAmount: 7500, expenseType: "desires" as const, tagId: "tag-transport", expenseDate: "2026-05-10", @@ -148,7 +150,7 @@ describe("computeChanges", () => { it("works with different currency formatting", () => { const corrected = { name: "Groceries", - amount: 7500, + transactionAmount: 7500, expenseType: "essentials" as const, tagId: "tag-food", expenseDate: "2026-05-02", @@ -166,7 +168,7 @@ describe("computeChanges", () => { ...baseExpense, id: "exp-2", name: "Updated Groceries", - amount: 6000, + transactionAmount: 6000, correctsId: "exp-1", }; diff --git a/frontend/apps/finance/src/features/expense-detail/__tests__/expense-detail-fields.test.tsx b/frontend/apps/finance/src/features/expense-detail/__tests__/expense-detail-fields.test.tsx index c241cf2b..93138a3e 100644 --- a/frontend/apps/finance/src/features/expense-detail/__tests__/expense-detail-fields.test.tsx +++ b/frontend/apps/finance/src/features/expense-detail/__tests__/expense-detail-fields.test.tsx @@ -17,8 +17,10 @@ const activeExpense: Expense = { id: "exp-1", userId: "user-1", name: "Groceries", - amount: 5000, transactionCurrency: "USD", + transactionAmount: 5000, + reportingAmount: 5000, + reportingCurrency: "USD", expenseType: "essentials", tagId: "tag-food", expenseDate: "2026-05-02", diff --git a/frontend/apps/finance/src/features/expense-detail/__tests__/expense-detail-modal.test.tsx b/frontend/apps/finance/src/features/expense-detail/__tests__/expense-detail-modal.test.tsx index 69438ae0..17d8d2c6 100644 --- a/frontend/apps/finance/src/features/expense-detail/__tests__/expense-detail-modal.test.tsx +++ b/frontend/apps/finance/src/features/expense-detail/__tests__/expense-detail-modal.test.tsx @@ -30,8 +30,10 @@ const activeExpense: Expense = { id: "exp-1", userId: "user-1", name: "Groceries", - amount: 5000, transactionCurrency: "USD", + transactionAmount: 5000, + reportingAmount: 5000, + reportingCurrency: "USD", expenseType: "essentials", tagId: "tag-food", expenseDate: "2026-05-02", @@ -46,8 +48,10 @@ const correctedExpense: Expense = { id: "exp-original", userId: "user-1", name: "Old Coffee", - amount: 500, transactionCurrency: "USD", + transactionAmount: 500, + reportingAmount: 500, + reportingCurrency: "USD", expenseType: "desires", tagId: "tag-food", expenseDate: "2026-05-01", @@ -62,8 +66,10 @@ const correctionExpense: Expense = { id: "exp-correction", userId: "user-1", name: "Updated Coffee", - amount: 600, transactionCurrency: "USD", + transactionAmount: 600, + reportingAmount: 600, + reportingCurrency: "USD", expenseType: "desires", tagId: "tag-food", expenseDate: "2026-05-01", @@ -104,8 +110,8 @@ function mockExpenseSuggestions( data: [ { name: "Train Pass", - amount: 1234, - currency: "USD", + transactionAmount: 1234, + transactionCurrency: "USD", expenseType: "desires", tagId: "tag-transport", frequency: 4, @@ -133,7 +139,7 @@ function mockCorrectionSuccess() { ...activeExpense, id: "exp-new-correction", name: "Corrected Groceries", - amount: 6000, + transactionAmount: 6000, correctsId: "exp-1", }, }), diff --git a/frontend/apps/finance/src/features/expense-detail/__tests__/use-expense-detail.test.ts b/frontend/apps/finance/src/features/expense-detail/__tests__/use-expense-detail.test.ts index 75a4affa..1b2d7d31 100644 --- a/frontend/apps/finance/src/features/expense-detail/__tests__/use-expense-detail.test.ts +++ b/frontend/apps/finance/src/features/expense-detail/__tests__/use-expense-detail.test.ts @@ -10,8 +10,10 @@ const testExpense: Expense = { id: "exp-1", userId: "user-1", name: "Groceries", - amount: 5000, transactionCurrency: "USD", + transactionAmount: 5000, + reportingAmount: 5000, + reportingCurrency: "USD", expenseType: "essentials", tagId: "tag-food", expenseDate: "2026-05-02", diff --git a/frontend/apps/finance/src/features/expense-detail/__tests__/useCorrectionForm.test.ts b/frontend/apps/finance/src/features/expense-detail/__tests__/useCorrectionForm.test.ts index 3c6b3eb3..b7898f7a 100644 --- a/frontend/apps/finance/src/features/expense-detail/__tests__/useCorrectionForm.test.ts +++ b/frontend/apps/finance/src/features/expense-detail/__tests__/useCorrectionForm.test.ts @@ -23,8 +23,8 @@ const mockTags: Tag[] = [ const mockSuggestion: ExpenseSuggestion = { name: "Train Pass", - amount: 1234, - currency: "USD", + transactionAmount: 1234, + transactionCurrency: "USD", expenseType: "desires", tagId: "tag-transport", frequency: 4, @@ -37,8 +37,10 @@ const mockExpense: Expense = { id: "exp-1", userId: "user-1", name: "Groceries", - amount: 5000, transactionCurrency: "USD", + transactionAmount: 5000, + reportingAmount: 5000, + reportingCurrency: "USD", expenseType: "essentials", tagId: "tag-food", expenseDate: "2026-05-02", @@ -82,7 +84,7 @@ describe("useCorrectionForm", () => { it("derives amountDollars correctly from cents", () => { const expenseWithOddAmount: Expense = { ...mockExpense, - amount: 1299, // $12.99 + transactionAmount: 1299, // $12.99 }; const onSubmit = vi.fn(); const { result } = renderHook(() => diff --git a/frontend/apps/finance/src/features/expense-detail/components/CorrectionTimeline.tsx b/frontend/apps/finance/src/features/expense-detail/components/CorrectionTimeline.tsx index 24d91290..38b8e47b 100644 --- a/frontend/apps/finance/src/features/expense-detail/components/CorrectionTimeline.tsx +++ b/frontend/apps/finance/src/features/expense-detail/components/CorrectionTimeline.tsx @@ -54,7 +54,7 @@ export function CorrectionTimeline({
- {entry.name} · {formatCurrency(entry.amount, currency)} ·{" "} + {entry.name} · {formatCurrency(entry.transactionAmount, currency)} ·{" "} {entry.expenseType} · {tagMap.get(entry.tagId) ?? entry.tagId}
{changes.length > 0 && ( diff --git a/frontend/apps/finance/src/features/expense-detail/components/DetailView.tsx b/frontend/apps/finance/src/features/expense-detail/components/DetailView.tsx index 0a442107..58be76c1 100644 --- a/frontend/apps/finance/src/features/expense-detail/components/DetailView.tsx +++ b/frontend/apps/finance/src/features/expense-detail/components/DetailView.tsx @@ -47,14 +47,14 @@ export function DetailView({ {correctedBy && (
This expense was corrected. See correction: {correctedBy.name} ( - {formatCurrency(correctedBy.amount, currency)}) + {formatCurrency(correctedBy.transactionAmount, currency)})
)} {correctsEntry && (
This corrects expense: {correctsEntry.name} ( - {formatCurrency(correctsEntry.amount, currency)}) + {formatCurrency(correctsEntry.transactionAmount, currency)})
)} @@ -62,7 +62,7 @@ export function DetailView({ sum + entry.amount, 0), + proRataGroup.reduce((sum, entry) => sum + entry.transactionAmount, 0), currency, )} /> @@ -136,7 +136,7 @@ export function DetailView({ {entry.id === expense.id && " (current)"} - {formatCurrency(entry.amount, currency)} · {entry.expenseDate} + {formatCurrency(entry.transactionAmount, currency)} · {entry.expenseDate}
))} diff --git a/frontend/apps/finance/src/features/expense-detail/hooks/useCorrectionForm.ts b/frontend/apps/finance/src/features/expense-detail/hooks/useCorrectionForm.ts index d314fe4f..2d1c7a8e 100644 --- a/frontend/apps/finance/src/features/expense-detail/hooks/useCorrectionForm.ts +++ b/frontend/apps/finance/src/features/expense-detail/hooks/useCorrectionForm.ts @@ -49,7 +49,7 @@ export function useCorrectionForm( const expenseFields = useExpenseFields( { name: expense.name, - amountDollars: toMajorUnits(expense.amount, expense.transactionCurrency).toFixed( + amountDollars: toMajorUnits(expense.transactionAmount, expense.transactionCurrency).toFixed( getMinorUnitDigits(expense.transactionCurrency), ), expenseType: expense.expenseType, diff --git a/frontend/apps/finance/src/features/expense-detail/utils/computeChanges.ts b/frontend/apps/finance/src/features/expense-detail/utils/computeChanges.ts index 9d056128..225d7af2 100644 --- a/frontend/apps/finance/src/features/expense-detail/utils/computeChanges.ts +++ b/frontend/apps/finance/src/features/expense-detail/utils/computeChanges.ts @@ -9,7 +9,7 @@ export interface ExpenseChange { interface CorrectionValues { name: string; - amount: number; + transactionAmount: number; expenseType: ExpenseType; tagId: string; expenseDate: string; @@ -33,11 +33,11 @@ export function computeChanges( changes.push({ field: "Name", from: original.name, to: corrected.name }); } - if (original.amount !== corrected.amount) { + if (original.transactionAmount !== corrected.transactionAmount) { changes.push({ field: "Amount", - from: formatCurrency(original.amount, currency), - to: formatCurrency(corrected.amount, currency), + from: formatCurrency(original.transactionAmount, currency), + to: formatCurrency(corrected.transactionAmount, currency), }); } diff --git a/frontend/apps/finance/src/features/expense-log/ExpenseLogFeature.tsx b/frontend/apps/finance/src/features/expense-log/ExpenseLogFeature.tsx index a0e5d19f..7359d6f1 100644 --- a/frontend/apps/finance/src/features/expense-log/ExpenseLogFeature.tsx +++ b/frontend/apps/finance/src/features/expense-log/ExpenseLogFeature.tsx @@ -13,16 +13,21 @@ import { ExpenseTable } from "./components/ExpenseTable"; import { ExpenseList } from "./components/ExpenseList"; import { PaginationControls } from "./components/PaginationControls"; import { ExpenseDetailModal } from "../expense-detail"; +import { ExpenseLogUnavailableCard } from "./components/ExpenseLogUnavailableCard"; +import { PeriodSelector } from "./components/PeriodSelector"; /** * Expense log feature orchestrator. Composes filter, data, and table hooks * and renders the appropriate view components. */ -export function ExpenseLogFeature({ user }: FinancePageProps) { +export function ExpenseLogFeature({ user: _user }: FinancePageProps) { + void _user; const now = new Date(); const filters = useExpenseFilters(); const data = useExpenseLogData(filters.criteria); - const { table } = useExpenseTable(data.expenses, user.currency); + const { table } = useExpenseTable( + data.state.status === "active" ? data.state.expenses : [], + ); const [selectedExpenseId, setSelectedExpenseId] = useState(null); @@ -36,11 +41,45 @@ export function ExpenseLogFeature({ user }: FinancePageProps) { setSelectedExpenseId(row.id); } - if (data.loading) { + if (data.state.status === "loading") { return ; } - const periodValue = `${data.selectedYear}-${data.selectedMonth}`; + if (data.state.status === "missing") { + return ( +
+
+ +

Expense Log

+
+ + + + +
+ ); + } + + if (data.state.status === "error") { + return ( + + ); + } + + const { expenses, tags, periods, reportingCurrency } = data.state; return (
@@ -50,42 +89,12 @@ export function ExpenseLogFeature({ user }: FinancePageProps) {
-
- - -
+
{filters.showFilters && ( - + )} - {data.expenses.length === 0 ? ( + {expenses.length === 0 ? ( @@ -140,7 +151,6 @@ export function ExpenseLogFeature({ user }: FinancePageProps) {
@@ -151,8 +161,8 @@ export function ExpenseLogFeature({ user }: FinancePageProps) { setSelectedExpenseId(null)} diff --git a/frontend/apps/finance/src/features/expense-log/__tests__/expense-log-mixed-currency.test.tsx b/frontend/apps/finance/src/features/expense-log/__tests__/expense-log-mixed-currency.test.tsx new file mode 100644 index 00000000..c12d04ad --- /dev/null +++ b/frontend/apps/finance/src/features/expense-log/__tests__/expense-log-mixed-currency.test.tsx @@ -0,0 +1,313 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router"; +import { ExpenseLogFeature } from "../index"; +import type { User, Expense, Tag, BudgetPeriod } from "@gofin/core"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +const mockUser: User = { + id: "user-1", + username: "alice", + email: "alice@example.com", + role: "user", + currency: "USD", + hasCompletedOnboarding: true, + createdAt: "2026-01-01T00:00:00Z", +}; + +const mockTags: Tag[] = [ + { id: "tag-food", name: "Food", isDefault: true, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" }, + { id: "tag-travel", name: "Travel", isDefault: true, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" }, +]; + +const mockPeriods: BudgetPeriod[] = [ + { + id: "period-may", + userId: "user-1", + year: 2026, + month: 5, + budgetAmount: 300000, + reportingCurrency: "USD", + essentialsPercent: 50, + desiresPercent: 30, + savingsPercent: 20, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, +]; + +/** Same-currency expense (USD transaction, USD reporting). */ +const sameCurrencyExpense: Expense = { + id: "exp-same", + userId: "user-1", + name: "Groceries", + transactionCurrency: "USD", + transactionAmount: 5000, + reportingAmount: 5000, + reportingCurrency: "USD", + exchangeRate: "1", + exchangeRateSource: "identity", + exchangeRateTimestamp: "2026-05-02T10:00:00Z", + expenseType: "essentials", + tagId: "tag-food", + expenseDate: "2026-05-02", + periodYear: 2026, + periodMonth: 5, + status: "active", + isProRata: false, + createdAt: "2026-05-02T10:00:00Z", +}; + +/** Foreign-currency expense (EUR transaction, USD reporting). */ +const foreignCurrencyExpense: Expense = { + id: "exp-foreign", + userId: "user-1", + name: "Hotel", + transactionCurrency: "EUR", + transactionAmount: 15000, + reportingAmount: 16200, + reportingCurrency: "USD", + exchangeRate: "1.08", + exchangeRateSource: "open_exchange_rates", + exchangeRateTimestamp: "2026-05-01T10:00:00Z", + expenseType: "desires", + tagId: "tag-travel", + expenseDate: "2026-05-01", + periodYear: 2026, + periodMonth: 5, + status: "active", + isProRata: false, + createdAt: "2026-05-01T09:00:00Z", +}; + +/** JPY transaction expense with USD reporting (zero-decimal transaction currency). */ +const jpyExpense: Expense = { + id: "exp-jpy", + userId: "user-1", + name: "Tokyo Lunch", + transactionCurrency: "JPY", + transactionAmount: 2000, + reportingAmount: 1350, + reportingCurrency: "USD", + exchangeRate: "0.00675", + exchangeRateSource: "open_exchange_rates", + exchangeRateTimestamp: "2026-05-03T10:00:00Z", + expenseType: "essentials", + tagId: "tag-food", + expenseDate: "2026-05-03", + periodYear: 2026, + periodMonth: 5, + status: "active", + isProRata: false, + createdAt: "2026-05-03T10:00:00Z", +}; + +function mockAllDataSuccess(expenses: Expense[] = [sameCurrencyExpense, foreignCurrencyExpense]) { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + data: expenses, + total: expenses.length, + page: 1, + pageSize: 1000, + hasMore: false, + }), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ tags: mockTags }), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ periods: mockPeriods }), + }); +} + +function renderExpenseLog(user: User = mockUser) { + return render( + + + , + ); +} + +describe("ExpenseLogFeature mixed-currency", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date("2026-05-12T12:00:00Z")); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("same-currency row shows one amount", async () => { + mockAllDataSuccess([sameCurrencyExpense]); + renderExpenseLog(); + + await waitFor(() => { + expect(screen.getAllByText("Groceries").length).toBeGreaterThanOrEqual(1); + }); + + // The amount column should show $50.00 (transaction = reporting) + expect(screen.getAllByText("$50.00").length).toBeGreaterThanOrEqual(1); + }); + + it("foreign-currency row shows transaction amount and secondary reporting amount", async () => { + mockAllDataSuccess([foreignCurrencyExpense]); + renderExpenseLog(); + + await waitFor(() => { + expect(screen.getAllByText("Hotel").length).toBeGreaterThanOrEqual(1); + }); + + // Transaction amount formatted in EUR + expect(screen.getAllByText("€150.00").length).toBeGreaterThanOrEqual(1); + // Secondary reporting amount formatted in USD with a distinguishing label + expect(screen.getAllByText("Budget impact: $162.00").length).toBeGreaterThanOrEqual(1); + }); + + it("JPY transaction shows zero-decimal transaction amount and USD reporting amount", async () => { + mockAllDataSuccess([jpyExpense]); + renderExpenseLog(); + + await waitFor(() => { + expect(screen.getAllByText("Tokyo Lunch").length).toBeGreaterThanOrEqual(1); + }); + + // JPY has 0 minor unit digits + expect(screen.getAllByText("¥2,000").length).toBeGreaterThanOrEqual(1); + // USD reporting amount + expect(screen.getAllByText("Budget impact: $13.50").length).toBeGreaterThanOrEqual(1); + }); + + it("sorts by reporting amount when clicking Amount header", async () => { + // exp-foreign has reportingAmount 16200, exp-same has reportingAmount 5000. + // Data order is [foreign, same] = [Hotel, Groceries]. + // After ascending sort by reportingAmount, same (5000) should come before foreign (16200). + mockAllDataSuccess([foreignCurrencyExpense, sameCurrencyExpense]); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderExpenseLog(); + + await waitFor(() => { + expect(screen.getAllByText("Groceries").length).toBeGreaterThanOrEqual(1); + }); + + // Click the Amount header button to sort ascending by reporting amount. + const amountHeader = screen.getByText("Amount").closest("button")!; + await user.click(amountHeader); + + const tableElement = document.querySelector("table")!; + let tableRows = tableElement.querySelectorAll("tbody tr"); + // Ascending by reportingAmount: Groceries (5000) before Hotel (16200) + await waitFor(() => { + tableRows = tableElement.querySelectorAll("tbody tr"); + expect(tableRows[0].textContent).toContain("Groceries"); + }); + expect(tableRows[1].textContent).toContain("Hotel"); + + // Descending: Hotel (16200) before Groceries (5000) + await user.click(amountHeader); + const updatedRows = tableElement.querySelectorAll("tbody tr"); + expect(updatedRows[0].textContent).toContain("Hotel"); + expect(updatedRows[1].textContent).toContain("Groceries"); + }); + + it("filters by transaction currency", async () => { + mockAllDataSuccess([sameCurrencyExpense, foreignCurrencyExpense]); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderExpenseLog(); + + await waitFor(() => { + expect(screen.getAllByText("Groceries").length).toBeGreaterThanOrEqual(1); + }); + + await user.click(screen.getByRole("button", { name: /filters/i })); + + // Click EUR in the Transaction Currency filter section. + const transactionCurrencySection = screen.getByText("Transaction Currency").closest("div")!; + const eurButton = within(transactionCurrencySection).getByRole("button", { name: "EUR" }); + await user.click(eurButton); + + // Only the EUR transaction expense should be visible + expect(screen.getAllByText("Hotel").length).toBeGreaterThanOrEqual(1); + expect(screen.queryByText("Groceries")).not.toBeInTheDocument(); + }); + + it("filters by reporting currency", async () => { + mockAllDataSuccess([sameCurrencyExpense, foreignCurrencyExpense]); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderExpenseLog(); + + await waitFor(() => { + expect(screen.getAllByText("Groceries").length).toBeGreaterThanOrEqual(1); + }); + + await user.click(screen.getByRole("button", { name: /filters/i })); + + // Click USD in the Reporting Currency filter section -- both rows have USD reporting + const reportingCurrencySection = screen.getByText("Reporting Currency").closest("div")!; + const usdButton = within(reportingCurrencySection).getByRole("button", { name: "USD" }); + await user.click(usdButton); + + // Both should still be visible since both report in USD + expect(screen.getAllByText("Groceries").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Hotel").length).toBeGreaterThanOrEqual(1); + }); + + it("applies AND logic for transaction and reporting currency filters", async () => { + mockAllDataSuccess([sameCurrencyExpense, foreignCurrencyExpense]); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderExpenseLog(); + + await waitFor(() => { + expect(screen.getAllByText("Groceries").length).toBeGreaterThanOrEqual(1); + }); + + await user.click(screen.getByRole("button", { name: /filters/i })); + + // Filter by EUR transaction currency AND USD reporting currency + const transactionCurrencySection = screen.getByText("Transaction Currency").closest("div")!; + const eurButton = within(transactionCurrencySection).getByRole("button", { name: "EUR" }); + await user.click(eurButton); + + const reportingCurrencySection = screen.getByText("Reporting Currency").closest("div")!; + const usdButton = within(reportingCurrencySection).getByRole("button", { name: "USD" }); + await user.click(usdButton); + + // Only the EUR/USD expense (Hotel) matches both filters + expect(screen.getAllByText("Hotel").length).toBeGreaterThanOrEqual(1); + expect(screen.queryByText("Groceries")).not.toBeInTheDocument(); + }); + + it("mobile list preserves secondary reporting amount for mixed-currency rows", async () => { + mockAllDataSuccess([foreignCurrencyExpense]); + renderExpenseLog(); + + await waitFor(() => { + expect(screen.getAllByText("Hotel").length).toBeGreaterThanOrEqual(1); + }); + + // Both the desktop table and mobile list are in the DOM (CSS controls + // visibility). For a single foreign expense, the labeled secondary amount + // must appear once in the desktop table and once in the mobile list. + const mobileList = document.querySelector(".md\\:hidden"); + expect(mobileList).not.toBeNull(); + const mobileReportingAmounts = mobileList + ? within(mobileList as HTMLElement).getAllByText("Budget impact: $162.00") + : []; + expect(mobileReportingAmounts.length).toBe(1); + + // The desktop table also renders the secondary amount. + const desktopReportingAmounts = screen.getAllByText("Budget impact: $162.00"); + expect(desktopReportingAmounts.length).toBeGreaterThanOrEqual(2); + }); +}); \ No newline at end of file diff --git a/frontend/apps/finance/src/features/expense-log/__tests__/expense-log-pagination.test.tsx b/frontend/apps/finance/src/features/expense-log/__tests__/expense-log-pagination.test.tsx index 0fc77de7..4a3f9c13 100644 --- a/frontend/apps/finance/src/features/expense-log/__tests__/expense-log-pagination.test.tsx +++ b/frontend/apps/finance/src/features/expense-log/__tests__/expense-log-pagination.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router"; @@ -49,8 +49,10 @@ function generateExpenses(count: number): Expense[] { id: `exp-${index}`, userId: "user-1", name: `Expense ${index + 1}`, - amount: 1000 + index * 100, transactionCurrency: "USD", + transactionAmount: 1000 + index * 100, + reportingAmount: 1000 + index * 100, + reportingCurrency: "USD", expenseType: "essentials" as const, tagId: "tag-food", expenseDate: "2026-05-01", @@ -97,13 +99,19 @@ function renderExpenseLog() { describe("ExpenseLogFeature - Pagination navigation", () => { beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date("2026-05-12T12:00:00Z")); mockFetch.mockReset(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("navigates to last page, then back to first via First page button", async () => { const expenses = generateExpenses(25); mockAllData(expenses); - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderExpenseLog(); await waitFor(() => { @@ -122,7 +130,7 @@ describe("ExpenseLogFeature - Pagination navigation", () => { it("navigates forward then back via Previous page button", async () => { const expenses = generateExpenses(25); mockAllData(expenses); - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderExpenseLog(); await waitFor(() => { diff --git a/frontend/apps/finance/src/features/expense-log/__tests__/expense-log.test.tsx b/frontend/apps/finance/src/features/expense-log/__tests__/expense-log.test.tsx index 5a3c230c..47ceb525 100644 --- a/frontend/apps/finance/src/features/expense-log/__tests__/expense-log.test.tsx +++ b/frontend/apps/finance/src/features/expense-log/__tests__/expense-log.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor, within, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router"; @@ -68,8 +68,10 @@ const mockExpenses: Expense[] = [ id: "exp-1", userId: "user-1", name: "Groceries", - amount: 5000, transactionCurrency: "USD", + transactionAmount: 5000, + reportingAmount: 5000, + reportingCurrency: "USD", expenseType: "essentials", tagId: "tag-food", expenseDate: "2026-05-02", @@ -83,8 +85,10 @@ const mockExpenses: Expense[] = [ id: "exp-2", userId: "user-1", name: "Bus Pass", - amount: 2000, transactionCurrency: "USD", + transactionAmount: 2000, + reportingAmount: 2000, + reportingCurrency: "USD", expenseType: "essentials", tagId: "tag-transport", expenseDate: "2026-05-01", @@ -98,8 +102,10 @@ const mockExpenses: Expense[] = [ id: "exp-3", userId: "user-1", name: "Old Coffee", - amount: 450, transactionCurrency: "USD", + transactionAmount: 450, + reportingAmount: 450, + reportingCurrency: "USD", expenseType: "desires", tagId: "tag-food", expenseDate: "2026-05-01", @@ -173,10 +179,16 @@ function renderExpenseLogWithSearchParams( describe("ExpenseLogFeature", () => { beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date("2026-05-12T12:00:00Z")); mockFetch.mockReset(); mockNavigate.mockReset(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("renders skeleton loading state initially", () => { mockFetch.mockReturnValueOnce(new Promise(() => {})); renderExpenseLog(); @@ -223,11 +235,11 @@ describe("ExpenseLogFeature", () => { renderExpenseLog({ ...mockUser, currency: "EUR" }); await waitFor(() => { - expect(screen.getAllByText("€50.00").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("$50.00").length).toBeGreaterThanOrEqual(1); }); - expect(screen.getAllByText("€20.00").length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText("€4.50").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("$20.00").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("$4.50").length).toBeGreaterThanOrEqual(1); }); it("shows total expense count", async () => { @@ -278,7 +290,7 @@ describe("ExpenseLogFeature", () => { describe("sorting", () => { it("sorts by column when clicking header", async () => { mockAllDataSuccess(); - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderExpenseLog(); await waitFor(() => { @@ -295,7 +307,7 @@ describe("ExpenseLogFeature", () => { it("toggles sort direction on repeated clicks", async () => { mockAllDataSuccess(); - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderExpenseLog(); await waitFor(() => { @@ -318,7 +330,7 @@ describe("ExpenseLogFeature", () => { describe("filtering", () => { it("shows filter panel when Filters button is clicked", async () => { mockAllDataSuccess(); - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderExpenseLog(); await waitFor(() => { @@ -334,7 +346,7 @@ describe("ExpenseLogFeature", () => { it("filters by expense type", async () => { mockAllDataSuccess(); - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderExpenseLog(); await waitFor(() => { @@ -352,7 +364,7 @@ describe("ExpenseLogFeature", () => { it("filters by tag", async () => { mockAllDataSuccess(); - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderExpenseLog(); await waitFor(() => { @@ -369,7 +381,7 @@ describe("ExpenseLogFeature", () => { it("applies combinatorial AND logic for filters", async () => { mockAllDataSuccess(); - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderExpenseLog(); await waitFor(() => { @@ -387,7 +399,7 @@ describe("ExpenseLogFeature", () => { it("filters by date range", async () => { mockAllDataSuccess(); - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderExpenseLog(); await waitFor(() => { @@ -406,7 +418,7 @@ describe("ExpenseLogFeature", () => { it("clears all filters when Clear filters is clicked", async () => { mockAllDataSuccess(); - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderExpenseLog(); await waitFor(() => { @@ -456,8 +468,10 @@ describe("ExpenseLogFeature", () => { id: `exp-${index}`, userId: "user-1", name: `Expense ${index + 1}`, - amount: 1000 + index * 100, transactionCurrency: "USD", + transactionAmount: 1000 + index * 100, + reportingAmount: 1000 + index * 100, + reportingCurrency: "USD", expenseType: "essentials" as const, tagId: "tag-food", expenseDate: `2026-05-${String(index + 1).padStart(2, "0")}`, @@ -469,7 +483,7 @@ describe("ExpenseLogFeature", () => { })); mockAllDataSuccess(manyExpenses); - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderExpenseLog(); await waitFor(() => { @@ -487,8 +501,10 @@ describe("ExpenseLogFeature", () => { id: `exp-${index}`, userId: "user-1", name: `Expense ${index + 1}`, - amount: 1000, transactionCurrency: "USD", + transactionAmount: 1000, + reportingAmount: 1000, + reportingCurrency: "USD", expenseType: "essentials" as const, tagId: "tag-food", expenseDate: "2026-05-01", @@ -500,7 +516,7 @@ describe("ExpenseLogFeature", () => { })); mockAllDataSuccess(manyExpenses); - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderExpenseLog(); await waitFor(() => { @@ -532,7 +548,7 @@ describe("ExpenseLogFeature", () => { it("reloads data when period is changed", async () => { mockAllDataSuccess(); - userEvent.setup(); + userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderExpenseLog(); await waitFor(() => { @@ -544,8 +560,10 @@ describe("ExpenseLogFeature", () => { id: "exp-apr-1", userId: "user-1", name: "April Rent", - amount: 100000, transactionCurrency: "USD", + transactionAmount: 100000, + reportingAmount: 100000, + reportingCurrency: "USD", expenseType: "essentials", tagId: "tag-bills", expenseDate: "2026-04-01", @@ -573,7 +591,7 @@ describe("ExpenseLogFeature", () => { describe("row click opens detail modal", () => { it("opens expense detail modal on row click", async () => { mockAllDataSuccess(); - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderExpenseLog(); await waitFor(() => { @@ -648,28 +666,46 @@ describe("ExpenseLogFeature", () => { }); }); - describe("error state", () => { - it("shows empty state when all fetches fail (toast handles error)", async () => { - mockFetch.mockRejectedValueOnce(new Error("Network error")); + describe("missing state", () => { + it("shows the unavailable card when the selected period is absent", async () => { + mockAllDataSuccess(mockExpenses, mockTags, [mockPeriods[1]]); renderExpenseLog(); await waitFor(() => { - expect(screen.getByText("No expenses for this period")).toBeInTheDocument(); + expect( + screen.getByText("No budget period for this month"), + ).toBeInTheDocument(); }); + + const periodSelect = screen.getByLabelText("Period:"); + expect(periodSelect).toBeInTheDocument(); + + const options = within(periodSelect).getAllByRole("option"); + expect(options).toHaveLength(1); + expect(options[0].textContent).toContain("April"); }); + }); - it("loads data on period change after initial failure", async () => { + describe("error state", () => { + it("shows the unavailable card with the error message when all fetches fail", async () => { mockFetch.mockRejectedValueOnce(new Error("Network error")); - userEvent.setup(); renderExpenseLog(); await waitFor(() => { - expect(screen.getByText("No expenses for this period")).toBeInTheDocument(); + expect( + screen.getByText("Failed to load expense log."), + ).toBeInTheDocument(); }); + const retryButton = screen.getByRole("button", { name: /retry/i }); + expect(retryButton).toBeInTheDocument(); + mockAllDataSuccess(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + await user.click(retryButton); + await waitFor(() => { - expect(screen.getByLabelText("Period:")).toBeDefined(); + expect(screen.getByText("Expense Log")).toBeInTheDocument(); }); }); }); diff --git a/frontend/apps/finance/src/features/expense-log/__tests__/useExpenseLogData.test.ts b/frontend/apps/finance/src/features/expense-log/__tests__/useExpenseLogData.test.ts index 8fd72d38..527eabf7 100644 --- a/frontend/apps/finance/src/features/expense-log/__tests__/useExpenseLogData.test.ts +++ b/frontend/apps/finance/src/features/expense-log/__tests__/useExpenseLogData.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, waitFor, act } from "@testing-library/react"; -import { useExpenseLogData, EMPTY_FETCH_RESULT } from "../hooks/useExpenseLogData"; +import { useExpenseLogData } from "../hooks/useExpenseLogData"; import type { FilterCriteria } from "../hooks/useExpenseFilters"; import type { Expense, Tag, BudgetPeriod } from "@gofin/core"; @@ -35,6 +35,8 @@ const mockGetPeriods = vi.mocked(expenseLogApi.getPeriods); const emptyFilters: FilterCriteria = { selectedTypes: new Set(), selectedTags: new Set(), + selectedTransactionCurrencies: new Set(), + selectedReportingCurrencies: new Set(), dateFrom: "", dateTo: "", }; @@ -44,8 +46,10 @@ const mockExpenses: Expense[] = [ id: "exp-1", userId: "user-1", name: "Groceries", - amount: 5000, transactionCurrency: "USD", + transactionAmount: 5000, + reportingAmount: 5000, + reportingCurrency: "USD", expenseType: "essentials", tagId: "tag-food", expenseDate: "2026-05-02", @@ -89,37 +93,24 @@ function mockSuccessfulFetch( describe("useExpenseLogData", () => { beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date("2026-05-12T12:00:00Z")); vi.clearAllMocks(); }); - describe("EMPTY_FETCH_RESULT constant", () => { - it("provides empty arrays for all fields", () => { - expect(EMPTY_FETCH_RESULT).toEqual({ - rawExpenses: [], - tags: [], - periods: [], - }); - }); - - it("is frozen/immutable reference", () => { - const ref1 = EMPTY_FETCH_RESULT; - const ref2 = EMPTY_FETCH_RESULT; - expect(ref1).toBe(ref2); - }); + afterEach(() => { + vi.useRealTimers(); }); describe("initial state", () => { - it("starts with loading=true", () => { + it("starts with loading state", () => { mockGetExpenses.mockReturnValue(new Promise(() => {})); mockGetTags.mockReturnValue(new Promise(() => {})); mockGetPeriods.mockReturnValue(new Promise(() => {})); const { result } = renderHook(() => useExpenseLogData(emptyFilters)); - expect(result.current.loading).toBe(true); - expect(result.current.expenses).toEqual([]); - expect(result.current.tags).toEqual([]); - expect(result.current.periods).toEqual([]); + expect(result.current.state).toEqual({ status: "loading" }); }); it("initializes selection to current year/month", () => { @@ -135,63 +126,84 @@ describe("useExpenseLogData", () => { }); }); - describe("fetch-result grouping", () => { + describe("active state", () => { it("replaces all fetch data atomically on success", async () => { mockSuccessfulFetch(); const { result } = renderHook(() => useExpenseLogData(emptyFilters)); await waitFor(() => { - expect(result.current.loading).toBe(false); + expect(result.current.state.status).toBe("active"); }); - expect(result.current.tags).toEqual(mockTags); - expect(result.current.periods).toEqual(mockPeriods); - expect(result.current.expenses).toHaveLength(1); - expect(result.current.expenses[0].name).toBe("Groceries"); + if (result.current.state.status !== "active") { + throw new Error("expected active state"); + } + + expect(result.current.state.tags).toEqual(mockTags); + expect(result.current.state.periods).toEqual(mockPeriods); + expect(result.current.state.expenses).toHaveLength(1); + expect(result.current.state.expenses[0].name).toBe("Groceries"); + expect(result.current.state.reportingCurrency).toBe("USD"); }); + }); - it("retains previous fetch-result when new fetch fails", async () => { - mockSuccessfulFetch(); + describe("error state", () => { + it("sets error when the fetch fails", async () => { + mockGetExpenses.mockRejectedValue(new Error("Network error")); + mockGetTags.mockRejectedValue(new Error("Network error")); + mockGetPeriods.mockRejectedValue(new Error("Network error")); const { result } = renderHook(() => useExpenseLogData(emptyFilters)); await waitFor(() => { - expect(result.current.loading).toBe(false); + expect(result.current.state.status).toBe("error"); }); - // Verify initial data is loaded - expect(result.current.tags).toEqual(mockTags); + if (result.current.state.status !== "error") { + throw new Error("expected error state"); + } - // Now make the next fetch fail - mockGetExpenses.mockRejectedValue(new Error("Network error")); - mockGetTags.mockRejectedValue(new Error("Network error")); - mockGetPeriods.mockRejectedValue(new Error("Network error")); + expect(result.current.state.message).toBe("Failed to load expense log."); + }); + }); + + describe("missing state", () => { + it("sets missing when the selected period is absent", async () => { + mockSuccessfulFetch(); + + const { result } = renderHook(() => useExpenseLogData(emptyFilters)); + + await waitFor(() => { + expect(result.current.state.status).toBe("active"); + }); act(() => { result.current.setSelectedMonth(4); }); await waitFor(() => { - expect(result.current.loading).toBe(false); + expect(result.current.state.status).toBe("missing"); }); - // Previous data remains (stale-but-dimmed approach) - expect(result.current.tags).toEqual(mockTags); + if (result.current.state.status !== "missing") { + throw new Error("expected missing state"); + } + + expect(result.current.state.periods).toEqual(mockPeriods); }); }); describe("loading transition on selection change", () => { - it("sets loading=true when selectedYear changes", async () => { + it("sets loading when selectedYear changes", async () => { mockSuccessfulFetch(); const { result } = renderHook(() => useExpenseLogData(emptyFilters)); await waitFor(() => { - expect(result.current.loading).toBe(false); + expect(result.current.state.status).toBe("active"); }); - // Stall the next fetch mockGetExpenses.mockReturnValue(new Promise(() => {})); mockGetTags.mockReturnValue(new Promise(() => {})); mockGetPeriods.mockReturnValue(new Promise(() => {})); @@ -200,20 +212,18 @@ describe("useExpenseLogData", () => { result.current.setSelectedYear(2025); }); - // Loading should be true immediately - expect(result.current.loading).toBe(true); + expect(result.current.state.status).toBe("loading"); }); - it("sets loading=true when selectedMonth changes", async () => { + it("sets loading when selectedMonth changes", async () => { mockSuccessfulFetch(); const { result } = renderHook(() => useExpenseLogData(emptyFilters)); await waitFor(() => { - expect(result.current.loading).toBe(false); + expect(result.current.state.status).toBe("active"); }); - // Stall the next fetch mockGetExpenses.mockReturnValue(new Promise(() => {})); mockGetTags.mockReturnValue(new Promise(() => {})); mockGetPeriods.mockReturnValue(new Promise(() => {})); @@ -222,17 +232,16 @@ describe("useExpenseLogData", () => { result.current.setSelectedMonth(3); }); - // Loading should be true immediately - expect(result.current.loading).toBe(true); + expect(result.current.state.status).toBe("loading"); }); - it("loading transitions to false after successful fetch", async () => { + it("transitions to active after a successful fetch for the new period", async () => { mockSuccessfulFetch(); const { result } = renderHook(() => useExpenseLogData(emptyFilters)); await waitFor(() => { - expect(result.current.loading).toBe(false); + expect(result.current.state.status).toBe("active"); }); const aprilExpenses: Expense[] = [{ @@ -241,17 +250,28 @@ describe("useExpenseLogData", () => { name: "April Purchase", periodMonth: 4, }]; - mockSuccessfulFetch(aprilExpenses); + const aprilPeriods: BudgetPeriod[] = [ + { + ...mockPeriods[0], + id: "period-apr", + month: 4, + }, + ]; + mockSuccessfulFetch(aprilExpenses, mockTags, aprilPeriods); act(() => { result.current.setSelectedMonth(4); }); await waitFor(() => { - expect(result.current.loading).toBe(false); + expect(result.current.state.status).toBe("active"); }); - expect(result.current.expenses[0].name).toBe("April Purchase"); + if (result.current.state.status !== "active") { + throw new Error("expected active state"); + } + + expect(result.current.state.expenses[0].name).toBe("April Purchase"); }); }); @@ -262,7 +282,7 @@ describe("useExpenseLogData", () => { const { result } = renderHook(() => useExpenseLogData(emptyFilters)); await waitFor(() => { - expect(result.current.loading).toBe(false); + expect(result.current.state.status).toBe("active"); }); act(() => { @@ -288,14 +308,10 @@ describe("useExpenseLogData", () => { const { result } = renderHook(() => useExpenseLogData(emptyFilters)); await waitFor(() => { - expect(result.current.loading).toBe(false); + expect(result.current.state.status).toBe("active"); }); - // Verify all fields of the public interface exist - expect(result.current).toHaveProperty("expenses"); - expect(result.current).toHaveProperty("tags"); - expect(result.current).toHaveProperty("periods"); - expect(result.current).toHaveProperty("loading"); + expect(result.current).toHaveProperty("state"); expect(result.current).toHaveProperty("selectedYear"); expect(result.current).toHaveProperty("selectedMonth"); expect(result.current).toHaveProperty("setSelectedYear"); diff --git a/frontend/apps/finance/src/features/expense-log/components/ExpenseList.tsx b/frontend/apps/finance/src/features/expense-log/components/ExpenseList.tsx index 54e9dc8f..296d332a 100644 --- a/frontend/apps/finance/src/features/expense-log/components/ExpenseList.tsx +++ b/frontend/apps/finance/src/features/expense-log/components/ExpenseList.tsx @@ -1,25 +1,32 @@ import type { Table } from "@tanstack/react-table"; -import { formatCurrency } from "@gofin/core"; +import { formatAmount } from "@gofin/core"; import { Card, CardContent } from "@gofin/ui/components/card"; import type { ExpenseRow } from "../../../lib/expense-table-columns"; interface ExpenseListProps { table: Table; - currency: string; onRowClick: (row: ExpenseRow) => void; } /** * Mobile list view for expenses. Renders a compact card with - * name, date, and formatted amount. Visible on mobile only via CSS. + * name, date, and formatted amount. For foreign-currency rows, + * shows the transaction amount and a smaller secondary reporting amount. + * Visible on mobile only via CSS. */ -export function ExpenseList({ table, currency, onRowClick }: ExpenseListProps) { +export function ExpenseList({ table, onRowClick }: ExpenseListProps) { return (
{table.getRowModel().rows.map((row) => { const expense = row.original; + const transactionFormatted = formatAmount( + expense.transactionAmountEffective, + expense.transactionCurrencyEffective, + ); + const className = expense.status === "corrected" ? "line-through" : ""; + return (
{expense.name} {expense.expenseDate} + {expense.showReportingAmount && ( + + Budget impact: {formatAmount( + expense.reportingAmountEffective, + expense.reportingCurrencyEffective, + )} + + )}
- {formatCurrency(expense.amount, currency)} + {transactionFormatted}
); @@ -60,4 +71,4 @@ export function ExpenseList({ table, currency, onRowClick }: ExpenseListProps) { ); -} +} \ No newline at end of file diff --git a/frontend/apps/finance/src/features/expense-log/components/ExpenseLogUnavailableCard.tsx b/frontend/apps/finance/src/features/expense-log/components/ExpenseLogUnavailableCard.tsx new file mode 100644 index 00000000..26db131b --- /dev/null +++ b/frontend/apps/finance/src/features/expense-log/components/ExpenseLogUnavailableCard.tsx @@ -0,0 +1,69 @@ +import { AlertTriangle, CalendarX2, RefreshCw } from "lucide-react"; +import { Button } from "@gofin/ui/components/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@gofin/ui/components/card"; + +interface ExpenseLogUnavailableCardProps { + year: number; + month: number; + errorMessage?: string; + onRetry?: () => void; +} + +export function ExpenseLogUnavailableCard({ + year, + month, + errorMessage, + onRetry, +}: ExpenseLogUnavailableCardProps) { + const monthName = new Date(year, month - 1).toLocaleString("en-US", { + month: "long", + year: "numeric", + }); + + return ( +
+ + + + {errorMessage ?? "No budget period for this month"} + + {!errorMessage && ( + + There is no budget period for {monthName}. + + )} + + + {errorMessage ? ( + + +
+ ); +} diff --git a/frontend/apps/finance/src/features/expense-log/components/FilterPanel.tsx b/frontend/apps/finance/src/features/expense-log/components/FilterPanel.tsx index f1afdbd3..afdb79bf 100644 --- a/frontend/apps/finance/src/features/expense-log/components/FilterPanel.tsx +++ b/frontend/apps/finance/src/features/expense-log/components/FilterPanel.tsx @@ -1,6 +1,6 @@ import type { Tag } from "@gofin/core"; +import { EXPENSE_TYPES, SUPPORTED_CURRENCIES } from "@gofin/core"; import type { ExpenseFilters } from "../hooks/useExpenseFilters"; -import { EXPENSE_TYPES } from "@gofin/core"; import { Input } from "@gofin/ui/components/input"; import { Card, CardContent } from "@gofin/ui/components/card"; @@ -63,6 +63,50 @@ export function FilterPanel({ filters, tags }: FilterPanelProps) {
)} +
+ + Transaction Currency + +
+ {SUPPORTED_CURRENCIES.map((currency) => ( + + ))} +
+
+ +
+ + Reporting Currency + +
+ {SUPPORTED_CURRENCIES.map((currency) => ( + + ))} +
+
+
Date Range diff --git a/frontend/apps/finance/src/features/expense-log/components/PeriodSelector.tsx b/frontend/apps/finance/src/features/expense-log/components/PeriodSelector.tsx new file mode 100644 index 00000000..2c4168bf --- /dev/null +++ b/frontend/apps/finance/src/features/expense-log/components/PeriodSelector.tsx @@ -0,0 +1,56 @@ +import type { BudgetPeriod } from "@gofin/core"; + +interface PeriodSelectorProps { + periods: BudgetPeriod[]; + selectedYear: number; + selectedMonth: number; + onChange: (value: string) => void; +} + +export function PeriodSelector({ + periods, + selectedYear, + selectedMonth, + onChange, +}: PeriodSelectorProps) { + const periodValue = `${selectedYear}-${selectedMonth}`; + + return ( +
+ + +
+ ); +} diff --git a/frontend/apps/finance/src/features/expense-log/hooks/useExpenseFilters.ts b/frontend/apps/finance/src/features/expense-log/hooks/useExpenseFilters.ts index 8aceedd9..b04a3046 100644 --- a/frontend/apps/finance/src/features/expense-log/hooks/useExpenseFilters.ts +++ b/frontend/apps/finance/src/features/expense-log/hooks/useExpenseFilters.ts @@ -1,45 +1,41 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useMemo } from "react"; import { useSearchParams } from "react-router"; -/** Immutable filter criteria object. */ export interface FilterCriteria { selectedTypes: Set; selectedTags: Set; + selectedTransactionCurrencies: Set; + selectedReportingCurrencies: Set; dateFrom: string; dateTo: string; } -/** Empty state constant for reset operations. */ export const EMPTY_CRITERIA: FilterCriteria = { selectedTypes: new Set(), selectedTags: new Set(), + selectedTransactionCurrencies: new Set(), + selectedReportingCurrencies: new Set(), dateFrom: "", dateTo: "", }; export interface ExpenseFilters { - /** Current filter criteria. */ criteria: FilterCriteria; - /** Whether the filter panel is visible. */ showFilters: boolean; - /** Derived: true if any filter is active. */ + /** Derived from the current criteria. */ hasActiveFilters: boolean; - /** Toggle a type in/out of the selected set. */ toggleType: (type: string) => void; - /** Toggle a tag in/out of the selected set. Syncs URL param. */ + /** Syncs the ?tag= URL param. */ toggleTag: (tagId: string) => void; - /** Toggle filter panel visibility. */ + toggleTransactionCurrency: (currency: string) => void; + toggleReportingCurrency: (currency: string) => void; toggleFilters: () => void; - /** Reset all filters to empty. */ clearFilters: () => void; - /** Set the start date filter. */ setDateFrom: (value: string) => void; - /** Set the end date filter. */ setDateTo: (value: string) => void; } /** - * Encapsulates expense filter state with URL search param sync. * Tag filter state is synced to/from the `?tag=` URL parameter. */ export function useExpenseFilters(): ExpenseFilters { @@ -51,22 +47,43 @@ export function useExpenseFilters(): ExpenseFilters { return { selectedTypes: new Set(), selectedTags: tagParam ? new Set([tagParam]) : new Set(), + selectedTransactionCurrencies: new Set(), + selectedReportingCurrencies: new Set(), dateFrom: "", dateTo: "", }; }); - const toggleType = useCallback((type: string) => { - setCriteria((prev) => { - const next = new Set(prev.selectedTypes); - if (next.has(type)) { - next.delete(type); - } else { - next.add(type); - } - return { ...prev, selectedTypes: next }; - }); - }, []); + const toggleSetField = useCallback( + ( + field: + | "selectedTypes" + | "selectedTransactionCurrencies" + | "selectedReportingCurrencies", + ) => + (value: string) => { + setCriteria((prev) => { + const next = new Set(prev[field]); + if (next.has(value)) { + next.delete(value); + } else { + next.add(value); + } + return { ...prev, [field]: next }; + }); + }, + [], + ); + + const toggleType = useMemo(() => toggleSetField("selectedTypes"), [toggleSetField]); + const toggleTransactionCurrency = useMemo( + () => toggleSetField("selectedTransactionCurrencies"), + [toggleSetField], + ); + const toggleReportingCurrency = useMemo( + () => toggleSetField("selectedReportingCurrencies"), + [toggleSetField], + ); const toggleTag = useCallback( (tagId: string) => { @@ -110,6 +127,8 @@ export function useExpenseFilters(): ExpenseFilters { const hasActiveFilters = criteria.selectedTypes.size > 0 || criteria.selectedTags.size > 0 || + criteria.selectedTransactionCurrencies.size > 0 || + criteria.selectedReportingCurrencies.size > 0 || criteria.dateFrom !== "" || criteria.dateTo !== ""; @@ -119,6 +138,8 @@ export function useExpenseFilters(): ExpenseFilters { hasActiveFilters, toggleType, toggleTag, + toggleTransactionCurrency, + toggleReportingCurrency, toggleFilters, clearFilters, setDateFrom, diff --git a/frontend/apps/finance/src/features/expense-log/hooks/useExpenseLogData.ts b/frontend/apps/finance/src/features/expense-log/hooks/useExpenseLogData.ts index e5208445..21080d89 100644 --- a/frontend/apps/finance/src/features/expense-log/hooks/useExpenseLogData.ts +++ b/frontend/apps/finance/src/features/expense-log/hooks/useExpenseLogData.ts @@ -5,6 +5,8 @@ import { expenseLogApi } from "../api"; import { resolveTagNames, type ExpenseRow } from "../../../lib/expense-table-columns"; import type { FilterCriteria } from "./useExpenseFilters"; +const EXPENSE_LOG_LOAD_ERROR = "Failed to load expense log."; + /** Result of the data fetch, grouped for atomic replacement. */ export interface ExpenseLogFetchResult { rawExpenses: Expense[]; @@ -12,18 +14,20 @@ export interface ExpenseLogFetchResult { periods: BudgetPeriod[]; } -/** Empty state constant for initial state and resets. */ -export const EMPTY_FETCH_RESULT: ExpenseLogFetchResult = { - rawExpenses: [], - tags: [], - periods: [], -}; +export type ExpenseLogDataState = + | { status: "loading" } + | { status: "missing"; periods: BudgetPeriod[] } + | { status: "error"; message: string } + | { + status: "active"; + expenses: ExpenseRow[]; + tags: Tag[]; + periods: BudgetPeriod[]; + reportingCurrency: string; + }; export interface ExpenseLogData { - expenses: ExpenseRow[]; - tags: Tag[]; - periods: BudgetPeriod[]; - loading: boolean; + state: ExpenseLogDataState; selectedYear: number; selectedMonth: number; setSelectedYear: (year: number) => void; @@ -33,22 +37,21 @@ export interface ExpenseLogData { /** * Encapsulates expense log data fetching, year/month selection, and - * filtering. Returns resolved expense rows (with tag names) already - * filtered by the current filter state. - * - * Internal state separates selection (year/month) from fetch results - * (expenses/tags/periods). When selection changes, `loading` is set to - * `true` immediately so consumers can dim stale content while the new - * fetch completes. + * filtering. Returns a discriminated union so callers cannot hold + * contradictory flags (loading + active, missing + error) at once. */ export function useExpenseLogData(filters: FilterCriteria): ExpenseLogData { const now = new Date(); const [selectedYear, setSelectedYear] = useState(now.getFullYear()); const [selectedMonth, setSelectedMonth] = useState(now.getMonth() + 1); - // Grouped fetch-result state: replaced atomically on success - const [fetchResult, setFetchResult] = useState(EMPTY_FETCH_RESULT); + const [fetchResult, setFetchResult] = useState({ + rawExpenses: [], + tags: [], + periods: [], + }); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); // Track whether this is the initial mount to avoid duplicate loading=true const isInitialMount = useRef(true); @@ -61,6 +64,7 @@ export function useExpenseLogData(filters: FilterCriteria): ExpenseLogData { const fetchData = useCallback(async () => { setLoading(true); + setError(null); const result = await toastCall(async () => { const [expensesResponse, tagsResponse, periodsResponse] = await Promise.all([ @@ -77,6 +81,8 @@ export function useExpenseLogData(filters: FilterCriteria): ExpenseLogData { tags: result.tagsResponse.tags, periods: result.periodsResponse.periods, }); + } else { + setError(EXPENSE_LOG_LOAD_ERROR); } setLoading(false); }, [selectedYear, selectedMonth, toastCall]); @@ -110,15 +116,34 @@ export function useExpenseLogData(filters: FilterCriteria): ExpenseLogData { if (filters.dateTo && row.expenseDate > filters.dateTo) { return false; } + if (filters.selectedTransactionCurrencies.size > 0 && !filters.selectedTransactionCurrencies.has(row.transactionCurrencyEffective)) { + return false; + } + if (filters.selectedReportingCurrencies.size > 0 && !filters.selectedReportingCurrencies.has(row.reportingCurrencyEffective)) { + return false; + } return true; }); - }, [fetchResult.rawExpenses, fetchResult.tags, filters.selectedTypes, filters.selectedTags, filters.dateFrom, filters.dateTo]); + }, [fetchResult.rawExpenses, fetchResult.tags, filters.selectedTypes, filters.selectedTags, filters.dateFrom, filters.dateTo, filters.selectedTransactionCurrencies, filters.selectedReportingCurrencies]); + + const state = useMemo(() => { + if (loading) return { status: "loading" }; + if (error) return { status: "error", message: error }; + const selected = fetchResult.periods.find( + (period) => period.year === selectedYear && period.month === selectedMonth, + ); + if (!selected) return { status: "missing", periods: fetchResult.periods }; + return { + status: "active", + expenses, + tags: fetchResult.tags, + periods: fetchResult.periods, + reportingCurrency: selected.reportingCurrency, + }; + }, [loading, error, fetchResult, selectedYear, selectedMonth, expenses]); return { - expenses, - tags: fetchResult.tags, - periods: fetchResult.periods, - loading, + state, selectedYear, selectedMonth, setSelectedYear, diff --git a/frontend/apps/finance/src/features/expense-log/hooks/useExpenseTable.ts b/frontend/apps/finance/src/features/expense-log/hooks/useExpenseTable.ts index 3ee4915e..bc654e05 100644 --- a/frontend/apps/finance/src/features/expense-log/hooks/useExpenseTable.ts +++ b/frontend/apps/finance/src/features/expense-log/hooks/useExpenseTable.ts @@ -21,14 +21,13 @@ export interface ExpenseTableState { */ export function useExpenseTable( data: ExpenseRow[], - currency: string, ): ExpenseTableState { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const columns = useMemo( - () => buildExpenseColumns(currency), - [currency], + () => buildExpenseColumns(), + [], ); const table = useReactTable({ @@ -37,6 +36,7 @@ export function useExpenseTable( state: { sorting, columnFilters }, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, + enableMultiSort: false, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), diff --git a/frontend/apps/finance/src/features/history/HistoryFeature.tsx b/frontend/apps/finance/src/features/history/HistoryFeature.tsx index b3ade106..e2002064 100644 --- a/frontend/apps/finance/src/features/history/HistoryFeature.tsx +++ b/frontend/apps/finance/src/features/history/HistoryFeature.tsx @@ -4,7 +4,6 @@ import type { BudgetPeriod } from "@gofin/core"; import { Button } from "@gofin/ui/components/button"; import { Card, CardContent } from "@gofin/ui/components/card"; import { History, ArrowLeft, Loader2 } from "lucide-react"; -import type { FinancePageProps } from "../../types"; import { ActiveDashboard } from "../dashboard"; import { useHistoryData } from "./hooks/useHistoryData"; import { PeriodRow } from "./components/PeriodRow"; @@ -13,7 +12,7 @@ import { PeriodRow } from "./components/PeriodRow"; * History page: shows past budget periods with spent/surplus data. * Clicking a period shows a read-only dashboard view. */ -export function HistoryFeature({ user }: FinancePageProps) { +export function HistoryFeature() { const { periods, loading } = useHistoryData(); const [selectedPeriod, setSelectedPeriod] = useState( null, @@ -66,7 +65,6 @@ export function HistoryFeature({ user }: FinancePageProps) { setSelectedPeriod(row.period)} /> ))} diff --git a/frontend/apps/finance/src/features/history/__tests__/history-navigation.test.tsx b/frontend/apps/finance/src/features/history/__tests__/history-navigation.test.tsx index 42be9015..4c29db7d 100644 --- a/frontend/apps/finance/src/features/history/__tests__/history-navigation.test.tsx +++ b/frontend/apps/finance/src/features/history/__tests__/history-navigation.test.tsx @@ -3,22 +3,11 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router"; import { HistoryFeature } from "../index"; -import type { User } from "@gofin/core"; import type { BudgetPeriod } from "@gofin/core"; const mockFetch = vi.fn(); global.fetch = mockFetch; -const mockUser: User = { - id: "user-1", - username: "alice", - email: "alice@example.com", - role: "user", - currency: "USD", - hasCompletedOnboarding: true, - createdAt: "2026-01-01T00:00:00Z", -}; - const mockPeriods: BudgetPeriod[] = [ { id: "period-march", @@ -71,7 +60,7 @@ function mockPeriodsAndSummary() { function renderHistory() { return render( - + , ); } diff --git a/frontend/apps/finance/src/features/history/__tests__/history.test.tsx b/frontend/apps/finance/src/features/history/__tests__/history.test.tsx index 8a12d947..67bce297 100644 --- a/frontend/apps/finance/src/features/history/__tests__/history.test.tsx +++ b/frontend/apps/finance/src/features/history/__tests__/history.test.tsx @@ -3,21 +3,10 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router"; import { HistoryFeature } from "../index"; -import type { User } from "@gofin/core"; const mockFetch = vi.fn(); global.fetch = mockFetch; -const mockUser: User = { - id: "user-1", - username: "alice", - email: "alice@example.com", - role: "user", - currency: "USD", - hasCompletedOnboarding: true, - createdAt: "2026-01-01T00:00:00Z", -}; - const mockPeriods = [ { id: "p3", @@ -84,7 +73,7 @@ function mockSummaryResponse(totalSpent: number) { function renderHistory() { return render( - + , ); } @@ -261,4 +250,79 @@ describe("HistoryFeature", () => { expect(screen.getByText("No budget periods yet.")).toBeInTheDocument(); }); }); + + // --- Reporting currency and mixed-currency guards --- + + it("formats each row with the period reporting currency, not user.currency", async () => { + const jpyPeriods = [ + { + ...mockPeriods[0], + id: "p3-jpy", + budgetAmount: 30000, + reportingCurrency: "JPY", + }, + ]; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ periods: jpyPeriods }), + }); + mockFetch.mockResolvedValueOnce(mockSummaryResponse(20000)); + + renderHistory(); + + await waitFor(() => { + expect(screen.getByText("March 2026")).toBeInTheDocument(); + }); + + // JPY has 0 minor unit digits, so no decimals. + expect(screen.getByText("Spent: ¥20,000")).toBeInTheDocument(); + expect(screen.getByText(/Budget:.*¥30,000/)).toBeInTheDocument(); + }); + + it("hides amount delta for adjacent rows with different reporting currencies", async () => { + const mixedPeriods = [ + { + ...mockPeriods[0], + id: "p3-usd", + reportingCurrency: "USD", + }, + { + ...mockPeriods[1], + id: "p2-eur", + reportingCurrency: "EUR", + }, + ]; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ periods: mixedPeriods }), + }); + mockFetch.mockResolvedValueOnce(mockSummaryResponse(200000)); // March USD + mockFetch.mockResolvedValueOnce(mockSummaryResponse(180000)); // Feb EUR + + renderHistory(); + + await waitFor(() => { + expect(screen.getByText("March 2026")).toBeInTheDocument(); + }); + + // The delta should be labeled not comparable. + expect(screen.getByText(/not comparable/i)).toBeInTheDocument(); + }); + + it("shows amount delta for adjacent rows with the same reporting currency", async () => { + mockPeriodsResponse(); + mockFetch.mockResolvedValueOnce(mockSummaryResponse(200000)); // March + mockFetch.mockResolvedValueOnce(mockSummaryResponse(280000)); // Feb + + renderHistory(); + + await waitFor(() => { + expect(screen.getByText("March 2026")).toBeInTheDocument(); + }); + + // Delta = 200000 - 280000 = -80000 => -$800.00 + expect(screen.getByText(/Δ.*\$800.00 from last/i)).toBeInTheDocument(); + }); }); diff --git a/frontend/apps/finance/src/features/history/__tests__/useHistoryData.test.ts b/frontend/apps/finance/src/features/history/__tests__/useHistoryData.test.ts index 85fe7d4f..898cf8d3 100644 --- a/frontend/apps/finance/src/features/history/__tests__/useHistoryData.test.ts +++ b/frontend/apps/finance/src/features/history/__tests__/useHistoryData.test.ts @@ -90,6 +90,7 @@ describe("useHistoryData", () => { status: "loaded", totalSpent: 200000, surplus: 100000, // 300000 - 200000 + deltaFromPrevious: { amount: -80000, comparable: true }, // 200000 - 280000 }); expect(result.current.periods[1]).toEqual({ period: mockPeriods[1], diff --git a/frontend/apps/finance/src/features/history/components/PeriodRow.tsx b/frontend/apps/finance/src/features/history/components/PeriodRow.tsx index dad0872a..8599fd88 100644 --- a/frontend/apps/finance/src/features/history/components/PeriodRow.tsx +++ b/frontend/apps/finance/src/features/history/components/PeriodRow.tsx @@ -6,11 +6,11 @@ import { PeriodSpendFigures } from "./PeriodSpendFigures"; interface PeriodRowProps { row: HistoricalPeriodRow; - currency: string; onSelect: () => void; } -export function PeriodRow({ row, currency, onSelect }: PeriodRowProps) { +export function PeriodRow({ row, onSelect }: PeriodRowProps) { + const reportingCurrency = row.period.reportingCurrency; const monthName = new Date( row.period.year, row.period.month - 1, @@ -26,7 +26,7 @@ export function PeriodRow({ row, currency, onSelect }: PeriodRowProps) {
{monthName} - Budget: {formatCurrency(row.period.budgetAmount, currency)}{" "} + Budget: {formatCurrency(row.period.budgetAmount, reportingCurrency)}{" "} · E/D/S: {row.period.essentialsPercent}/{row.period.desiresPercent}/ {row.period.savingsPercent} @@ -37,7 +37,8 @@ export function PeriodRow({ row, currency, onSelect }: PeriodRowProps) { ) : ( <> @@ -55,4 +56,4 @@ export function PeriodRow({ row, currency, onSelect }: PeriodRowProps) { ); -} +} \ No newline at end of file diff --git a/frontend/apps/finance/src/features/history/components/PeriodSpendFigures.tsx b/frontend/apps/finance/src/features/history/components/PeriodSpendFigures.tsx index 69e9bac1..f943187c 100644 --- a/frontend/apps/finance/src/features/history/components/PeriodSpendFigures.tsx +++ b/frontend/apps/finance/src/features/history/components/PeriodSpendFigures.tsx @@ -1,4 +1,5 @@ import { formatCurrency } from "@gofin/core"; +import type { PeriodDelta } from "../types"; interface PeriodSpendFiguresProps { /** Amount spent in the period, in minor currency units. */ @@ -6,6 +7,8 @@ interface PeriodSpendFiguresProps { /** budgetAmount minus totalSpent. Negative means a deficit. */ surplus: number; currency: string; + /** Delta from the previous period, when both rows loaded. */ + delta?: PeriodDelta; } /** Spend and surplus figures for a period whose summary loaded. */ @@ -13,6 +16,7 @@ export function PeriodSpendFigures({ totalSpent, surplus, currency, + delta, }: PeriodSpendFiguresProps) { const isSurplus = surplus >= 0; @@ -29,6 +33,13 @@ export function PeriodSpendFigures({ {isSurplus ? "Surplus" : "Deficit"}:{" "} {formatCurrency(Math.abs(surplus), currency)}

+ {delta && ( +

+ {delta.comparable + ? `Δ ${delta.amount >= 0 ? "+" : ""}${formatCurrency(Math.abs(delta.amount), currency)} from last` + : "Δ not comparable (different currency)"} +

+ )} ); -} +} \ No newline at end of file diff --git a/frontend/apps/finance/src/features/history/hooks/useHistoryData.ts b/frontend/apps/finance/src/features/history/hooks/useHistoryData.ts index 41296876..80030d9a 100644 --- a/frontend/apps/finance/src/features/history/hooks/useHistoryData.ts +++ b/frontend/apps/finance/src/features/history/hooks/useHistoryData.ts @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { apiClient, useApiToast } from "@gofin/api"; import type { PeriodListResponse, SummaryResponse } from "@gofin/core"; -import type { HistoricalPeriodRow, HistoryDataResult } from "../types"; +import type { HistoricalPeriodRow, LoadedPeriodRow, HistoryDataResult, PeriodDelta } from "../types"; /** * Fetches all budget periods and computes totalSpent/surplus for each. A period @@ -44,7 +44,26 @@ export function useHistoryData(): HistoryDataResult { }); if (result) { - setPeriods(result); + // Compute deltas between adjacent loaded rows, guarded by currency + // comparability. The API returns periods in year DESC, month DESC order; + // we keep that order for display, so "previous" is the next row in the + // list (the older month). + const rows = result as LoadedPeriodRow[]; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (row.status !== "loaded") continue; + const prev = rows[i + 1]; + if (prev && prev.status === "loaded") { + const comparable = + row.period.reportingCurrency === prev.period.reportingCurrency; + const delta: PeriodDelta = { + amount: row.totalSpent - prev.totalSpent, + comparable, + }; + row.deltaFromPrevious = delta; + } + } + setPeriods(rows); } setLoading(false); } diff --git a/frontend/apps/finance/src/features/history/types.ts b/frontend/apps/finance/src/features/history/types.ts index 8e38e8c4..d129fd99 100644 --- a/frontend/apps/finance/src/features/history/types.ts +++ b/frontend/apps/finance/src/features/history/types.ts @@ -1,18 +1,26 @@ import type { BudgetPeriod } from "@gofin/core"; -/** Base properties available in all history row states. */ +export interface PeriodDelta { + /** Signed amount difference (current totalSpent - previous totalSpent). */ + amount: number; + /** False when the two periods have different reporting currencies. */ + comparable: boolean; +} + interface HistoricalPeriodRowBase { - /** The budget period the row describes. */ period: BudgetPeriod; } -/** The period's summary loaded. A totalSpent of 0 means the user spent nothing. */ +/** A totalSpent of 0 means the user spent nothing. */ export interface LoadedPeriodRow extends HistoricalPeriodRowBase { status: "loaded"; /** Amount spent in the period, in minor currency units. */ totalSpent: number; /** budgetAmount minus totalSpent. Negative means a deficit. */ surplus: number; + /** Delta from the previous row when both rows are loaded. Undefined for the + * first row or when the previous row is unavailable. */ + deltaFromPrevious?: PeriodDelta; } /** The period's summary fetch failed, so its spend is unknown. */ @@ -31,8 +39,6 @@ export interface UnavailablePeriodRow extends HistoricalPeriodRowBase { export type HistoricalPeriodRow = LoadedPeriodRow | UnavailablePeriodRow; export interface HistoryDataResult { - /** Historical period rows, each either loaded or unavailable. */ periods: HistoricalPeriodRow[]; - /** Whether data is currently being fetched. */ loading: boolean; -} +} \ No newline at end of file diff --git a/frontend/apps/finance/src/features/new-expense/__mocks__/fetch.ts b/frontend/apps/finance/src/features/new-expense/__mocks__/fetch.ts index ce8efae7..b49fc6c5 100644 --- a/frontend/apps/finance/src/features/new-expense/__mocks__/fetch.ts +++ b/frontend/apps/finance/src/features/new-expense/__mocks__/fetch.ts @@ -58,7 +58,7 @@ export function setNewExpenseFetchMock( expense: { id: "exp-123", name: "Coffee", - amount: 450, + transactionAmount: 450, expenseType: "desires", status: "active", }, diff --git a/frontend/apps/finance/src/features/new-expense/__mocks__/fixtures.ts b/frontend/apps/finance/src/features/new-expense/__mocks__/fixtures.ts index d90e520c..96183d5f 100644 --- a/frontend/apps/finance/src/features/new-expense/__mocks__/fixtures.ts +++ b/frontend/apps/finance/src/features/new-expense/__mocks__/fixtures.ts @@ -32,8 +32,8 @@ export const mockSuggestions: ExpenseSuggestionsResponse = { data: [ { name: "Coffee Shop", - amount: 450, - currency: "USD", + transactionAmount: 450, + transactionCurrency: "USD", expenseType: "desires", tagId: "tag-food", frequency: 4, @@ -43,8 +43,8 @@ export const mockSuggestions: ExpenseSuggestionsResponse = { }, { name: "Coffee Beans", - amount: 1200, - currency: "USD", + transactionAmount: 1200, + transactionCurrency: "USD", expenseType: "essentials", tagId: "tag-food", frequency: 2, diff --git a/frontend/apps/finance/src/features/new-expense/__tests__/new-expense-autocomplete.test.tsx b/frontend/apps/finance/src/features/new-expense/__tests__/new-expense-autocomplete.test.tsx index 1c8f64fc..4905248b 100644 --- a/frontend/apps/finance/src/features/new-expense/__tests__/new-expense-autocomplete.test.tsx +++ b/frontend/apps/finance/src/features/new-expense/__tests__/new-expense-autocomplete.test.tsx @@ -114,9 +114,9 @@ describe("NewExpenseFeature autocomplete integration", () => { await user.type(screen.getByLabelText("Name"), "Coffee"); expect(await screen.findByText("Coffee Shop")).toBeInTheDocument(); - expect(screen.getByText("Frecency score: 42")).toBeInTheDocument(); + expect(screen.getByText("$4.50 · USD · Frecency: 42")).toBeInTheDocument(); expect(screen.getByText("Coffee Beans")).toBeInTheDocument(); - expect(screen.getByText("Frecency score: 31")).toBeInTheDocument(); + expect(screen.getByText("$12.00 · USD · Frecency: 31")).toBeInTheDocument(); }); it("places load more after visible suggestions when the latest page has more results", async () => { @@ -130,8 +130,8 @@ describe("NewExpenseFeature autocomplete integration", () => { const listbox = await screen.findByRole("listbox"); const options = within(listbox).getAllByRole("option"); expect(options.map((option) => option.textContent)).toEqual([ - "Coffee ShopFrecency score: 42", - "Coffee BeansFrecency score: 31", + "Coffee Shop$4.50 · USD · Frecency: 42", + "Coffee Beans$12.00 · USD · Frecency: 31", "Load more suggestions", ]); }); @@ -165,7 +165,7 @@ describe("NewExpenseFeature autocomplete integration", () => { const secondPageSuggestion = { ...mockSuggestions.data[0], name: "Custom Coffee Roaster", - amount: 2200, + transactionAmount: 2200, frecencyScore: 18, }; @@ -216,7 +216,7 @@ describe("NewExpenseFeature autocomplete integration", () => { const secondPageSuggestion = { ...mockSuggestions.data[0], name: "Coffee Cart", - amount: 900, + transactionAmount: 900, frecencyScore: 12, }; @@ -351,6 +351,51 @@ describe("NewExpenseFeature autocomplete integration", () => { expect(screen.queryByLabelText("Number of months")).not.toBeInTheDocument(); }); + it("keeps a foreign suggestion currency selected and submits transactionCurrency", async () => { + const user = userEvent.setup(); + const eurSuggestion: ExpenseSuggestionsResponse = { + data: [ + { + name: "Hotel Berlin", + transactionAmount: 15000, + transactionCurrency: "EUR", + expenseType: "desires", + tagId: "tag-food", + frequency: 3, + lastUsedAt: "2026-05-20T00:00:00Z", + recencyBucket: "last_30_days", + frecencyScore: 25, + }, + ], + total: 1, + page: 1, + pageSize: 50, + hasMore: false, + }; + renderNewExpense(eurSuggestion); + + await waitForFormBootstrap(); + + await user.type(screen.getByLabelText("Name"), "Hotel"); + await user.click(await screen.findByText("Hotel Berlin")); + + // The suggestion currency differs from the USD period reporting currency, + // so the form keeps EUR selected. + expect(screen.getByLabelText("Transaction Currency")).toHaveValue("EUR"); + expect(screen.getByLabelText("Amount")).toHaveValue(150); + + await user.click(screen.getByRole("button", { name: "Log Expense" })); + + await waitFor(() => { + expect(mockToastSuccess).toHaveBeenCalledWith("Expense saved"); + }); + + const body = getSubmittedExpenseRequest(); + expect(body.amount).toBe(15000); + expect(body.transactionCurrency).toBe("EUR"); + expect(body.currency).toBeUndefined(); + }); + it("applies the same autofill behavior with ArrowDown and Enter", async () => { const user = userEvent.setup(); renderNewExpense(); diff --git a/frontend/apps/finance/src/features/new-expense/__tests__/new-expense.test.tsx b/frontend/apps/finance/src/features/new-expense/__tests__/new-expense.test.tsx index 3aff3f19..10295e59 100644 --- a/frontend/apps/finance/src/features/new-expense/__tests__/new-expense.test.tsx +++ b/frontend/apps/finance/src/features/new-expense/__tests__/new-expense.test.tsx @@ -346,4 +346,95 @@ describe("NewExpenseFeature", () => { ) as HTMLInputElement; expect(essentialsRadio.checked).toBe(false); }); + + it("submits foreign-currency payload with transactionCurrency and shows conversion-unavailable banner on FX failure", async () => { + const user = userEvent.setup(); + renderNewExpense(); + + await waitForFormBootstrap(); + + setNewExpenseFetchMock({ + expensePost: () => + jsonResponse( + { + code: "CONVERSION_UNAVAILABLE", + message: + "Conversion unavailable. Try again later, or enter the manually converted amount in the period currency.", + }, + 503, + ), + }); + + await user.type(screen.getByLabelText("Name"), "Cafe abroad"); + await user.selectOptions( + screen.getByLabelText("Transaction Currency"), + "EUR", + ); + await user.type(screen.getByLabelText("Amount"), "12.50"); + await user.click(screen.getByRole("button", { name: "Log Expense" })); + + // The POST payload carries transactionCurrency (not legacy currency). + const postCall = findExpensePostCall(); + expect(postCall).toBeDefined(); + const body = JSON.parse(postCall![1].body); + expect(body.transactionCurrency).toBe("EUR"); + expect(body.currency).toBeUndefined(); + + // The conversion-unavailable guidance banner is shown. + await waitFor(() => { + expect( + screen.getByText(/Conversion unavailable/i), + ).toBeInTheDocument(); + }); + + // The toast shows the conversion-unavailable guidance, not the generic failure. + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith( + expect.stringContaining("Conversion unavailable"), + ); + }); + + // Form values are preserved after the failed foreign-currency save. + expect(screen.getByLabelText("Name")).toHaveValue("Cafe abroad"); + expect(screen.getByLabelText("Amount")).toHaveValue(12.5); + expect(screen.getByLabelText("Transaction Currency")).toHaveValue("EUR"); + + expect(mockToastSuccess).not.toHaveBeenCalled(); + }); + + it("does not show a partially saved expense after FX failure (no refetch of expenses list)", async () => { + const user = userEvent.setup(); + renderNewExpense(); + + await waitForFormBootstrap(); + + setNewExpenseFetchMock({ + expensePost: () => + jsonResponse( + { + code: "CONVERSION_UNAVAILABLE", + message: + "Conversion unavailable. Try again later, or enter the manually converted amount in the period currency.", + }, + 503, + ), + }); + + await user.type(screen.getByLabelText("Name"), "FX fail"); + await user.selectOptions( + screen.getByLabelText("Transaction Currency"), + "GBP", + ); + await user.type(screen.getByLabelText("Amount"), "10.00"); + await user.click(screen.getByRole("button", { name: "Log Expense" })); + + await waitFor(() => { + expect(screen.getByText(/Conversion unavailable/i)).toBeInTheDocument(); + }); + + // The form is still visible (not navigated away or reset), so the user + // can retry without retyping. No expense list refetch is triggered. + expect(screen.getByLabelText("Name")).toHaveValue("FX fail"); + expect(screen.getByRole("button", { name: "Log Expense" })).toBeEnabled(); + }); }); diff --git a/frontend/apps/finance/src/features/new-expense/hooks/useNewExpenseForm.ts b/frontend/apps/finance/src/features/new-expense/hooks/useNewExpenseForm.ts index 1cd7ce1d..fe8689e6 100644 --- a/frontend/apps/finance/src/features/new-expense/hooks/useNewExpenseForm.ts +++ b/frontend/apps/finance/src/features/new-expense/hooks/useNewExpenseForm.ts @@ -1,5 +1,5 @@ import { useState, useEffect, type SyntheticEvent } from "react"; -import { apiClient, useFormMutation } from "@gofin/api"; +import { ApiRequestError, apiClient, useFormMutation } from "@gofin/api"; import { toast } from "sonner"; import { EXPENSE_TYPES, type ExpenseType } from "@gofin/core"; import type { ExpenseFields } from "../../../lib/validate-expense-fields"; @@ -89,7 +89,15 @@ export function useNewExpenseForm( toast.success(SUCCESS_TOAST_BY_KIND[submittedKind]); resetNewExpenseVisibleState(); }, - onError: () => { + onError: (message, cause) => { + // FX conversion-unavailable failures show the spec-mandated guidance + // toast instead of the generic failure message. The form error banner + // (state.error) already carries the server's guidance copy. Form values + // are preserved so the user can retry or manually convert. + if (cause instanceof ApiRequestError && cause.code === "CONVERSION_UNAVAILABLE") { + toast.error(message); + return; + } toast.error("Failed to save expense"); }, }); diff --git a/frontend/apps/finance/src/lib/expense-table-columns.tsx b/frontend/apps/finance/src/lib/expense-table-columns.tsx index 43f64f61..e3c316f6 100644 --- a/frontend/apps/finance/src/lib/expense-table-columns.tsx +++ b/frontend/apps/finance/src/lib/expense-table-columns.tsx @@ -1,16 +1,20 @@ import { createColumnHelper } from "@tanstack/react-table"; -import { formatCurrency } from "@gofin/core"; +import { formatAmount } from "@gofin/core"; import type { Expense, Tag } from "@gofin/core"; -/** Enriched expense with resolved tag name for display. */ export interface ExpenseRow extends Expense { tagName: string; + transactionAmountEffective: number; + transactionCurrencyEffective: string; + reportingAmountEffective: number; + reportingCurrencyEffective: string; + /** True when transaction currency differs from reporting currency. */ + showReportingAmount: boolean; } const columnHelper = createColumnHelper(); -/** Build table column definitions for the expense log. */ -export function buildExpenseColumns(currency: string) { +export function buildExpenseColumns() { return [ columnHelper.accessor("expenseDate", { header: "Date", @@ -28,17 +32,42 @@ export function buildExpenseColumns(currency: string) { ); }, }), - columnHelper.accessor("amount", { + columnHelper.accessor("reportingAmountEffective", { header: "Amount", cell: (info) => { const row = info.row.original; + const transactionFormatted = formatAmount( + row.transactionAmountEffective, + row.transactionCurrencyEffective, + ); + const className = row.status === "corrected" ? "line-through" : ""; + + if (!row.showReportingAmount) { + return ( + {transactionFormatted} + ); + } + + // Foreign-currency row: show transaction amount and secondary reporting amount. + const reportingFormatted = formatAmount( + row.reportingAmountEffective, + row.reportingCurrencyEffective, + ); return ( - - {formatCurrency(info.getValue(), currency)} - +
+ {transactionFormatted} + + Budget impact: {reportingFormatted} + +
); }, - sortingFn: "basic", + sortingFn: (rowA, rowB) => + (rowA.original?.reportingAmountEffective ?? 0) - (rowB.original?.reportingAmountEffective ?? 0), + sortDescFirst: false, }), columnHelper.accessor("expenseType", { header: "Type", @@ -72,17 +101,27 @@ export function buildExpenseColumns(currency: string) { ]; } -/** - * Resolve tag names by mapping tag IDs to tag name strings. - * Falls back to the tag ID if the tag is not found. - */ export function resolveTagNames( expenses: Expense[], tags: Tag[], ): ExpenseRow[] { const tagMap = new Map(tags.map((tag) => [tag.id, tag.name])); - return expenses.map((expense) => ({ - ...expense, - tagName: tagMap.get(expense.tagId) ?? expense.tagId, - })); -} + return expenses.map((expense) => { + const transactionAmountEffective = expense.transactionAmount; + const transactionCurrencyEffective = expense.transactionCurrency; + const reportingAmountEffective = expense.reportingAmount; + const reportingCurrencyEffective = expense.reportingCurrency; + const showReportingAmount = + transactionCurrencyEffective !== reportingCurrencyEffective; + + return { + ...expense, + tagName: tagMap.get(expense.tagId) ?? expense.tagId, + transactionAmountEffective, + transactionCurrencyEffective, + reportingAmountEffective, + reportingCurrencyEffective, + showReportingAmount, + }; + }); +} \ No newline at end of file diff --git a/frontend/apps/shell/app/__tests__/history-route.test.tsx b/frontend/apps/shell/app/__tests__/history-route.test.tsx index 91d6dacf..aabbf1c8 100644 --- a/frontend/apps/shell/app/__tests__/history-route.test.tsx +++ b/frontend/apps/shell/app/__tests__/history-route.test.tsx @@ -5,8 +5,8 @@ import { useAuthStore } from "@/stores/auth-store"; // Mock the finance feature module so this test does not pull in its real tree vi.mock("@gofin/finance/src/features/history", () => ({ - HistoryFeature: ({ user }: { user: { username: string } }) => ( -
Budget History for {user.username}
+ HistoryFeature: () => ( +
Budget History
), })); @@ -70,7 +70,7 @@ describe("HistoryRoute", () => { }); expect( - screen.getByText("Budget History for testuser"), + screen.getByText("Budget History"), ).toBeInTheDocument(); }); diff --git a/frontend/apps/shell/app/routes/history.tsx b/frontend/apps/shell/app/routes/history.tsx index 384c0f29..cafd758d 100644 --- a/frontend/apps/shell/app/routes/history.tsx +++ b/frontend/apps/shell/app/routes/history.tsx @@ -41,7 +41,7 @@ export default function HistoryRoute() { sectionName="History" loadingFallback={} > - + ); } diff --git a/frontend/apps/shell/mocks/data/dashboard.ts b/frontend/apps/shell/mocks/data/dashboard.ts index 361e3bb9..56c3f165 100644 --- a/frontend/apps/shell/mocks/data/dashboard.ts +++ b/frontend/apps/shell/mocks/data/dashboard.ts @@ -10,7 +10,7 @@ import { mockExpenses } from "./expenses"; import { mockTags } from "./tags"; function computeSummary(): PeriodSummary { - const totalSpent = mockExpenses.reduce((sum, expense) => sum + expense.amount, 0); + const totalSpent = mockExpenses.reduce((sum, expense) => sum + expense.transactionAmount, 0); const totalBudget = mockPeriod.budgetAmount; const remaining = totalBudget - totalSpent; const daysRemaining = daysInMonth - daysElapsed; @@ -20,13 +20,13 @@ function computeSummary(): PeriodSummary { const essentialsSpent = mockExpenses .filter((expense) => expense.expenseType === "essentials") - .reduce((sum, expense) => sum + expense.amount, 0); + .reduce((sum, expense) => sum + expense.transactionAmount, 0); const desiresSpent = mockExpenses .filter((expense) => expense.expenseType === "desires") - .reduce((sum, expense) => sum + expense.amount, 0); + .reduce((sum, expense) => sum + expense.transactionAmount, 0); const savingsSpent = mockExpenses .filter((expense) => expense.expenseType === "savings") - .reduce((sum, expense) => sum + expense.amount, 0); + .reduce((sum, expense) => sum + expense.transactionAmount, 0); const dailySpendRate = daysElapsed > 0 ? Math.round(totalSpent / daysElapsed) : 0; const idealDailyRate = daysInMonth > 0 ? Math.round(totalBudget / daysInMonth) : 0; @@ -68,10 +68,10 @@ function computeSummary(): PeriodSummary { export const mockSummary: PeriodSummary = computeSummary(); export function computeTagSpending(): TagSpending[] { - const totalSpent = mockExpenses.reduce((sum, expense) => sum + expense.amount, 0); + const totalSpent = mockExpenses.reduce((sum, expense) => sum + expense.transactionAmount, 0); const byTag = new Map(); mockExpenses.forEach((expense) => { - byTag.set(expense.tagId, (byTag.get(expense.tagId) ?? 0) + expense.amount); + byTag.set(expense.tagId, (byTag.get(expense.tagId) ?? 0) + expense.transactionAmount); }); return Array.from(byTag.entries()) @@ -96,7 +96,7 @@ export function computeCumulativeSpend(): CumulativeSpendPoint[] { const dayExpenses = mockExpenses .filter((expense) => expense.expenseDate === dateStr) - .reduce((sum, expense) => sum + expense.amount, 0); + .reduce((sum, expense) => sum + expense.transactionAmount, 0); cumulative += dayExpenses; @@ -113,6 +113,8 @@ export function computeCumulativeSpend(): CumulativeSpendPoint[] { export const mockComparison: HistoricalComparison = { currentSpent: mockSummary.totalSpent, previousSpent: 285000, // $2,850 last month + previousReportingCurrency: "USD", + comparable: true, rollingAverage: 275000, // $2,750 rolling 3-month avg changePercent: -5.3, }; diff --git a/frontend/apps/shell/mocks/data/expenses.ts b/frontend/apps/shell/mocks/data/expenses.ts index 91d6c720..f2d06f19 100644 --- a/frontend/apps/shell/mocks/data/expenses.ts +++ b/frontend/apps/shell/mocks/data/expenses.ts @@ -5,44 +5,51 @@ import { tagIds } from "./tags"; export const mockExpenses: Expense[] = [ { - id: uuid(), userId: adminUser.id, name: "Rent", amount: 120000, + id: uuid(), userId: adminUser.id, name: "Rent", transactionCurrency: "USD", expenseType: "essentials", tagId: tagIds.bills, + transactionAmount: 120000, reportingAmount: 120000, reportingCurrency: "USD", expenseDate: daysAgoISO(0), periodYear: currentYear, periodMonth: currentMonth, status: "active", isProRata: false, createdAt: new Date().toISOString(), }, { - id: uuid(), userId: adminUser.id, name: "Grocery run", amount: 8540, + id: uuid(), userId: adminUser.id, name: "Grocery run", transactionCurrency: "USD", expenseType: "essentials", tagId: tagIds.food, + transactionAmount: 8540, reportingAmount: 8540, reportingCurrency: "USD", expenseDate: daysAgoISO(1), periodYear: currentYear, periodMonth: currentMonth, status: "active", isProRata: false, createdAt: new Date().toISOString(), }, { - id: uuid(), userId: adminUser.id, name: "Concert tickets", amount: 15000, + id: uuid(), userId: adminUser.id, name: "Concert tickets", transactionCurrency: "USD", expenseType: "desires", tagId: tagIds.recreation, + transactionAmount: 15000, reportingAmount: 15000, reportingCurrency: "USD", expenseDate: daysAgoISO(2), periodYear: currentYear, periodMonth: currentMonth, status: "active", isProRata: false, createdAt: new Date().toISOString(), }, { - id: uuid(), userId: adminUser.id, name: "Bus pass", amount: 7500, + id: uuid(), userId: adminUser.id, name: "Bus pass", transactionCurrency: "USD", expenseType: "essentials", tagId: tagIds.transport, + transactionAmount: 7500, reportingAmount: 7500, reportingCurrency: "USD", expenseDate: daysAgoISO(3), periodYear: currentYear, periodMonth: currentMonth, status: "active", isProRata: false, createdAt: new Date().toISOString(), }, { - id: uuid(), userId: adminUser.id, name: "Online course", amount: 4999, + id: uuid(), userId: adminUser.id, name: "Online course", transactionCurrency: "USD", expenseType: "savings", tagId: tagIds.selfInvestment, + transactionAmount: 4999, reportingAmount: 4999, reportingCurrency: "USD", expenseDate: daysAgoISO(4), periodYear: currentYear, periodMonth: currentMonth, status: "active", isProRata: false, createdAt: new Date().toISOString(), }, { - id: uuid(), userId: adminUser.id, name: "Dinner with friends", amount: 4200, + id: uuid(), userId: adminUser.id, name: "Dinner with friends", transactionCurrency: "USD", expenseType: "desires", tagId: tagIds.social, + transactionAmount: 4200, reportingAmount: 4200, reportingCurrency: "USD", expenseDate: daysAgoISO(5), periodYear: currentYear, periodMonth: currentMonth, status: "active", isProRata: false, createdAt: new Date().toISOString(), }, { - id: uuid(), userId: adminUser.id, name: "Coffee beans", amount: 1899, + id: uuid(), userId: adminUser.id, name: "Coffee beans", transactionCurrency: "USD", expenseType: "desires", tagId: tagIds.coffee, + transactionAmount: 1899, reportingAmount: 1899, reportingCurrency: "USD", expenseDate: daysAgoISO(6), periodYear: currentYear, periodMonth: currentMonth, status: "active", isProRata: false, createdAt: new Date().toISOString(), }, diff --git a/frontend/apps/shell/mocks/data/health.ts b/frontend/apps/shell/mocks/data/health.ts index fed34a9f..542322ee 100644 --- a/frontend/apps/shell/mocks/data/health.ts +++ b/frontend/apps/shell/mocks/data/health.ts @@ -105,7 +105,7 @@ export function computeMockHealthScore(): HealthScore { const sumByType = (type: string) => mockExpenses .filter((expense) => expense.expenseType === type) - .reduce((sum, expense) => sum + expense.amount, 0); + .reduce((sum, expense) => sum + expense.transactionAmount, 0); const essentialsActual = sumByType("essentials"); const desiresActual = sumByType("desires"); const savingsActual = sumByType("savings"); @@ -200,6 +200,7 @@ export function computeMockHealthScore(): HealthScore { band, provisional: true, formulaVersion: 2, + reportingCurrency: "USD", components, insight, }; @@ -278,7 +279,7 @@ export function computeMockHealthScoreTrend(months: number): HealthScoreTrendPoi const total = provisional ? mockHealthScore.total : MOCK_TREND_TOTALS[(count - 1 - offset) % MOCK_TREND_TOTALS.length]; - points.push({ year, month, total, band: bandFor(total), provisional, formulaVersion: 2 }); + points.push({ year, month, total, band: bandFor(total), provisional, formulaVersion: 2, reportingCurrency: "USD" }); } return points; } diff --git a/frontend/apps/shell/mocks/data/trends.ts b/frontend/apps/shell/mocks/data/trends.ts index ef06d465..3cfed903 100644 --- a/frontend/apps/shell/mocks/data/trends.ts +++ b/frontend/apps/shell/mocks/data/trends.ts @@ -45,6 +45,7 @@ export function computeMockTrends(months: number): TrendPoint[] { essentialsPercent: 50, desiresPercent: 30, savingsPercent: 20, + reportingCurrency: "USD", }); month++; diff --git a/frontend/apps/shell/mocks/handlers/expenses.ts b/frontend/apps/shell/mocks/handlers/expenses.ts index 06e760cc..fa2d3a0c 100644 --- a/frontend/apps/shell/mocks/handlers/expenses.ts +++ b/frontend/apps/shell/mocks/handlers/expenses.ts @@ -34,10 +34,14 @@ export const expensesHandlers = [ async ({ request }) => { await simulateLatency(); const body = await request.json(); + const { amount, ...rest } = body; const expense: Expense = { id: crypto.randomUUID(), userId: currentMockUser.id, - ...body, + ...rest, + transactionAmount: amount, + reportingAmount: amount, + reportingCurrency: rest.transactionCurrency, status: "active", isProRata: false, createdAt: new Date().toISOString(), @@ -66,16 +70,19 @@ export const expensesHandlers = [ async ({ request, params }) => { await simulateLatency(); const body = await request.json(); + const { amount, ...rest } = body; const original = mockExpenses.find((e) => e.id === params.id); const base: Expense = original ?? { id: params.id, userId: currentMockUser.id, - name: body.name, - amount: body.amount, + name: rest.name, transactionCurrency: currentMockUser.currency, - expenseType: body.expenseType, - tagId: body.tagId, - expenseDate: body.expenseDate, + transactionAmount: amount, + reportingAmount: amount, + reportingCurrency: currentMockUser.currency, + expenseType: rest.expenseType, + tagId: rest.tagId, + expenseDate: rest.expenseDate, periodYear: new Date().getFullYear(), periodMonth: new Date().getMonth() + 1, status: "active", @@ -85,7 +92,9 @@ export const expensesHandlers = [ const correction: Expense = { ...base, id: crypto.randomUUID(), - ...body, + ...rest, + transactionAmount: amount, + reportingAmount: amount, status: "active", correctsId: params.id, createdAt: new Date().toISOString(), diff --git a/frontend/apps/shell/mocks/handlers/prorata.ts b/frontend/apps/shell/mocks/handlers/prorata.ts index bd73a401..103419e8 100644 --- a/frontend/apps/shell/mocks/handlers/prorata.ts +++ b/frontend/apps/shell/mocks/handlers/prorata.ts @@ -18,8 +18,10 @@ export const prorataHandlers = [ id: crypto.randomUUID(), userId: currentMockUser.id, name: body.name, - amount: Math.round(body.totalAmount / body.months), transactionCurrency: body.transactionCurrency, + transactionAmount: Math.round(body.totalAmount / body.months), + reportingAmount: Math.round(body.totalAmount / body.months), + reportingCurrency: body.transactionCurrency, expenseType: body.expenseType, tagId: body.tagId, expenseDate: body.expenseDate, diff --git a/frontend/packages/core/src/types/dashboard.ts b/frontend/packages/core/src/types/dashboard.ts index 8df9ea7e..3891d4b7 100644 --- a/frontend/packages/core/src/types/dashboard.ts +++ b/frontend/packages/core/src/types/dashboard.ts @@ -1,4 +1,4 @@ -/** Category breakdown within a period summary (E/D/S). */ +/** Category breakdown (E/D/S). */ export interface CategorySummary { /** Allocated amount in minor units (cents). */ allocated: number; @@ -10,7 +10,7 @@ export interface CategorySummary { percentUsed: number; } -/** Full period summary returned by GET /api/finance/summary. */ +/** Returned by GET /api/finance/summary. */ export interface PeriodSummary { periodId: string; year: number; @@ -39,7 +39,6 @@ export interface SummaryResponse { summary: PeriodSummary; } -/** Single tag's spending data for the tag spending chart. */ export interface TagSpending { tagId: string; tagName: string; @@ -54,7 +53,6 @@ export interface TagSpendingResponse { tagSpending: TagSpending[]; } -/** One data point in the cumulative spend chart. */ export interface CumulativeSpendPoint { /** Day of month (1-31). */ day: number; @@ -69,15 +67,18 @@ export interface CumulativeSpendResponse { points: CumulativeSpendPoint[]; } -/** Historical spending comparison data. */ export interface HistoricalComparison { /** Current period total spent in minor units (cents). */ currentSpent: number; /** Previous period total spent in minor units (cents). */ previousSpent: number; - /** Rolling average of last 3 periods' totalSpent. Null if < 3 periods. */ + /** Reporting currency of the previous period. Empty when no previous period. */ + previousReportingCurrency: string; + /** False when current and previous periods have different reporting currencies. */ + comparable: boolean; + /** Rolling average of last 3 periods' totalSpent. Null if < 3 periods or mixed currencies. */ rollingAverage: number | null; - /** Percentage change from previous period. */ + /** Percentage change from previous period. Only meaningful when comparable is true. */ changePercent: number; } diff --git a/frontend/packages/core/src/types/expenses.ts b/frontend/packages/core/src/types/expenses.ts index 7a9736e5..913ddc0a 100644 --- a/frontend/packages/core/src/types/expenses.ts +++ b/frontend/packages/core/src/types/expenses.ts @@ -8,8 +8,6 @@ export interface Expense { id: string; userId: string; name: string; - /** Amount in transaction currency minor units. */ - amount: number; /** Canonical transaction ISO 4217 currency code. */ transactionCurrency: string; /** One of: "essentials", "desires", "savings". */ @@ -26,6 +24,21 @@ export interface Expense { proRataIndex?: number; proRataTotal?: number; createdAt: string; + // --- Money snapshot fields (backfilled for every row) --- + /** Original amount in transaction currency minor units. */ + transactionAmount: number; + /** Converted amount in the period reporting currency minor units. */ + reportingAmount: number; + /** Budget period reporting currency for this ledger row. */ + reportingCurrency: string; + /** Source-to-target exchange rate used for this row. */ + exchangeRate?: string; + /** "open_exchange_rates" | "identity" | "migration". */ + exchangeRateSource?: string; + /** Provider, identity, or migration timestamp. */ + exchangeRateTimestamp?: string; + /** Present for live provider snapshots with cache expiry metadata. */ + exchangeRateExpiresAt?: string; } /** Response from POST /api/expenses. */ diff --git a/frontend/packages/core/src/types/healthscore.ts b/frontend/packages/core/src/types/healthscore.ts index 6d312f13..d1998e96 100644 --- a/frontend/packages/core/src/types/healthscore.ts +++ b/frontend/packages/core/src/types/healthscore.ts @@ -12,7 +12,7 @@ export type HealthComponentKey = | "allocation_balance" | "spending_stability"; -/** One contributing sub-score. Score is in [0, max]; max is the weight. */ +/** Score is in [0, max]; max is the weight. */ export interface HealthComponent { /** * Component key. Open union: the backend may add or rename components across @@ -35,7 +35,7 @@ export interface HealthInsight { nudge: string; } -/** Monthly financial health score from GET /api/finance/health-score. */ +/** Returned by GET /api/finance/health-score. */ export interface HealthScore { year: number; month: number; @@ -45,6 +45,8 @@ export interface HealthScore { /** True for the current (open) month; firms up when the month closes. */ provisional: boolean; formulaVersion: number; + /** The scored period's immutable reporting currency. */ + reportingCurrency: string; components: HealthComponent[]; insight: HealthInsight; } @@ -70,6 +72,8 @@ export interface HealthScoreTrendPoint { band: HealthBand; provisional: boolean; formulaVersion: number; + /** The scored period's immutable reporting currency. */ + reportingCurrency: string; } /** Response from GET /api/finance/health-score/trend. */ diff --git a/frontend/packages/core/src/types/trends.ts b/frontend/packages/core/src/types/trends.ts index 148f1947..a51a4f98 100644 --- a/frontend/packages/core/src/types/trends.ts +++ b/frontend/packages/core/src/types/trends.ts @@ -1,4 +1,3 @@ -/** A single monthly data point in the spending trends chart. */ export interface TrendPoint { year: number; month: number; @@ -18,6 +17,8 @@ export interface TrendPoint { desiresPercent: number; /** Budgeted savings percentage (0-100). */ savingsPercent: number; + /** The period's reporting currency for this data point. */ + reportingCurrency: string; } /** Response from GET /api/finance/spending/trends. */ diff --git a/frontend/packages/test-utils/src/__tests__/factories.test.ts b/frontend/packages/test-utils/src/__tests__/factories.test.ts index db0d7998..d0758f8e 100644 --- a/frontend/packages/test-utils/src/__tests__/factories.test.ts +++ b/frontend/packages/test-utils/src/__tests__/factories.test.ts @@ -82,7 +82,7 @@ describe("buildExpense", () => { expect(expense.id).toMatch(/^expense-\d+$/); expect(expense.userId).toBe("user-1"); expect(expense.name).toBe("Test Expense"); - expect(expense.amount).toBe(5000); + expect(expense.transactionAmount).toBe(5000); expect(expense.transactionCurrency).toBe("USD"); expect(expense.expenseType).toBe("essentials"); expect(expense.tagId).toBe("tag-1"); @@ -104,12 +104,12 @@ describe("buildExpense", () => { it("accepts partial overrides", () => { const expense = buildExpense({ name: "Coffee", - amount: 450, + transactionAmount: 450, expenseType: "desires", }); expect(expense.name).toBe("Coffee"); - expect(expense.amount).toBe(450); + expect(expense.transactionAmount).toBe(450); expect(expense.expenseType).toBe("desires"); expect(expense.userId).toBe("user-1"); }); diff --git a/frontend/packages/test-utils/src/factories.ts b/frontend/packages/test-utils/src/factories.ts index 5346354d..2c8fcc0b 100644 --- a/frontend/packages/test-utils/src/factories.ts +++ b/frontend/packages/test-utils/src/factories.ts @@ -53,8 +53,10 @@ export function buildExpense(overrides?: Partial): Expense { id: nextId("expense"), userId: "user-1", name: "Test Expense", - amount: 5000, transactionCurrency: "USD", + transactionAmount: 5000, + reportingAmount: 5000, + reportingCurrency: "USD", expenseType: "essentials", tagId: "tag-1", expenseDate: "2026-01-15", diff --git a/services/datarights/internal/engine/export_helpers_test.go b/services/datarights/internal/engine/export_helpers_test.go index 6e5eb6a1..c49eaa0a 100644 --- a/services/datarights/internal/engine/export_helpers_test.go +++ b/services/datarights/internal/engine/export_helpers_test.go @@ -164,13 +164,13 @@ func cannedExpensePages() []*expensepb.ExpenseListResponse { { Data: []*expensepb.ExpenseData{ { - Id: "exp-1", Name: "Groceries", Amount: 4599, Currency: "USD", + Id: "exp-1", Name: "Groceries", TransactionAmount: 4599, TransactionCurrency: "USD", ExpenseType: "essentials", TagId: "tag-1", ExpenseDate: "2026-05-01", PeriodYear: 2026, PeriodMonth: 5, Status: "active", CreatedAt: "2026-05-01T12:00:00Z", }, { - Id: "exp-2", Name: "Bus pass", Amount: 3000, Currency: "USD", + Id: "exp-2", Name: "Bus pass", TransactionAmount: 3000, TransactionCurrency: "USD", ExpenseType: "essentials", TagId: "tag-2", ExpenseDate: "2026-05-02", PeriodYear: 2026, PeriodMonth: 5, Status: "active", CreatedAt: "2026-05-02T09:00:00Z", diff --git a/services/datarights/internal/engine/providers/expenses.go b/services/datarights/internal/engine/providers/expenses.go index ab29528c..adf561c5 100644 --- a/services/datarights/internal/engine/providers/expenses.go +++ b/services/datarights/internal/engine/providers/expenses.go @@ -49,7 +49,7 @@ func (p *ExpensesProvider) Name() string { // Headers returns the CSV column headers for expense data. func (p *ExpensesProvider) Headers() []string { return []string{ - "id", "name", "amount", "currency", "expense_type", "tag_name", + "id", "name", "transaction_amount", "transaction_currency", "expense_type", "tag_name", "expense_date", "period_year", "period_month", "status", "corrects_id", "is_pro_rata", "pro_rata_group", "pro_rata_index", "pro_rata_total", "created_at", @@ -128,7 +128,7 @@ func (p *ExpensesProvider) streamExpenses( // formatRow converts a single expense into a CSV row with all transformations applied. func (p *ExpensesProvider) formatRow(exp *expensepb.ExpenseData, tagMap map[string]string) []string { tagName := resolveTagName(exp.GetTagId(), tagMap) - amount := formatCentsToDollars(exp.GetAmount()) + amount := formatCentsToDollars(exp.GetTransactionAmount()) isProRata := formatBool(exp.GetIsProRata()) // Pro-rata fields: render empty string for non-pro-rata expenses @@ -140,7 +140,7 @@ func (p *ExpensesProvider) formatRow(exp *expensepb.ExpenseData, tagMap map[stri exp.GetId(), exp.GetName(), amount, - exp.GetCurrency(), + exp.GetTransactionCurrency(), exp.GetExpenseType(), tagName, exp.GetExpenseDate(), diff --git a/services/datarights/internal/engine/providers/expenses_stream_test.go b/services/datarights/internal/engine/providers/expenses_stream_test.go index 2eea7bfc..bd70ef1b 100644 --- a/services/datarights/internal/engine/providers/expenses_stream_test.go +++ b/services/datarights/internal/engine/providers/expenses_stream_test.go @@ -21,17 +21,17 @@ func streamRowFixtures(n int) []*expensepb.ExpenseData { rows := make([]*expensepb.ExpenseData, n) for i := range rows { rows[i] = &expensepb.ExpenseData{ - Id: fmt.Sprintf("exp-%08d", i), - Name: "Expense", - Amount: int64(1000 + i), - Currency: "USD", - ExpenseType: "essentials", - TagId: "tag-1", - ExpenseDate: "2026-05-01", - PeriodYear: 2026, - PeriodMonth: 5, - Status: "active", - CreatedAt: fmt.Sprintf("2026-05-01T%02d:%02d:%02dZ", i/3600%24, i/60%60, i%60), + Id: fmt.Sprintf("exp-%08d", i), + Name: "Expense", + TransactionAmount: int64(1000 + i), + TransactionCurrency: "USD", + ExpenseType: "essentials", + TagId: "tag-1", + ExpenseDate: "2026-05-01", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + CreatedAt: fmt.Sprintf("2026-05-01T%02d:%02d:%02dZ", i/3600%24, i/60%60, i%60), } } return rows diff --git a/services/datarights/internal/engine/providers/expenses_test.go b/services/datarights/internal/engine/providers/expenses_test.go index b4aa19d1..f220befe 100644 --- a/services/datarights/internal/engine/providers/expenses_test.go +++ b/services/datarights/internal/engine/providers/expenses_test.go @@ -19,7 +19,7 @@ func TestExpensesProvider_Name(t *testing.T) { func TestExpensesProvider_Headers(t *testing.T) { p := NewExpensesProvider(nil, nil) expected := []string{ - "id", "name", "amount", "currency", "expense_type", "tag_name", + "id", "name", "transaction_amount", "transaction_currency", "expense_type", "tag_name", "expense_date", "period_year", "period_month", "status", "corrects_id", "is_pro_rata", "pro_rata_group", "pro_rata_index", "pro_rata_total", "created_at", @@ -34,18 +34,18 @@ func TestExpensesProvider_Collect_Success(t *testing.T) { expenseClient := &mockExpenseServiceClient{ streamRows: []*expensepb.ExpenseData{ { - Id: "exp-1", - Name: "Groceries", - Amount: 4599, - Currency: "USD", - ExpenseType: "essentials", - TagId: "tag-1", - ExpenseDate: "2026-05-01", - PeriodYear: 2026, - PeriodMonth: 5, - Status: "active", - IsProRata: false, - CreatedAt: "2026-05-01T12:00:00Z", + Id: "exp-1", + Name: "Groceries", + TransactionAmount: 4599, + TransactionCurrency: "USD", + ExpenseType: "essentials", + TagId: "tag-1", + ExpenseDate: "2026-05-01", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + IsProRata: false, + CreatedAt: "2026-05-01T12:00:00Z", }, }, } @@ -70,21 +70,21 @@ func TestExpensesProvider_Collect_ProRataExpense(t *testing.T) { expenseClient := &mockExpenseServiceClient{ streamRows: []*expensepb.ExpenseData{ { - Id: "exp-pr-1", - Name: "Rent (1/3)", - Amount: 50000, - Currency: "USD", - ExpenseType: "essentials", - TagId: "tag-1", - ExpenseDate: "2026-05-01", - PeriodYear: 2026, - PeriodMonth: 5, - Status: "active", - IsProRata: true, - ProRataGroup: "group-abc", - ProRataIndex: 1, - ProRataTotal: 3, - CreatedAt: "2026-05-01T10:00:00Z", + Id: "exp-pr-1", + Name: "Rent (1/3)", + TransactionAmount: 50000, + TransactionCurrency: "USD", + ExpenseType: "essentials", + TagId: "tag-1", + ExpenseDate: "2026-05-01", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + IsProRata: true, + ProRataGroup: "group-abc", + ProRataIndex: 1, + ProRataTotal: 3, + CreatedAt: "2026-05-01T10:00:00Z", }, }, } @@ -109,17 +109,17 @@ func TestExpensesProvider_Collect_MissingTagResolvesToUnknown(t *testing.T) { expenseClient := &mockExpenseServiceClient{ streamRows: []*expensepb.ExpenseData{ { - Id: "exp-1", - Name: "Mystery", - Amount: 1000, - Currency: "USD", - ExpenseType: "desires", - TagId: "deleted-tag-id", - ExpenseDate: "2026-01-15", - PeriodYear: 2026, - PeriodMonth: 1, - Status: "active", - CreatedAt: "2026-01-15T08:00:00Z", + Id: "exp-1", + Name: "Mystery", + TransactionAmount: 1000, + TransactionCurrency: "USD", + ExpenseType: "desires", + TagId: "deleted-tag-id", + ExpenseDate: "2026-01-15", + PeriodYear: 2026, + PeriodMonth: 1, + Status: "active", + CreatedAt: "2026-01-15T08:00:00Z", }, }, } @@ -139,9 +139,9 @@ func TestExpensesProvider_Collect_MultipleRowsInStreamOrder(t *testing.T) { // created_at ASC, id ASC); the consumer does not page. expenseClient := &mockExpenseServiceClient{ streamRows: []*expensepb.ExpenseData{ - {Id: "exp-1", Name: "First", Amount: 100, TagId: "tag-1", PeriodYear: 2026, PeriodMonth: 1, CreatedAt: "2026-01-01T00:00:00Z"}, - {Id: "exp-2", Name: "Second", Amount: 200, TagId: "tag-1", PeriodYear: 2026, PeriodMonth: 1, CreatedAt: "2026-01-02T00:00:00Z"}, - {Id: "exp-3", Name: "Third", Amount: 300, TagId: "tag-1", PeriodYear: 2026, PeriodMonth: 2, CreatedAt: "2026-02-01T00:00:00Z"}, + {Id: "exp-1", Name: "First", TransactionAmount: 100, TagId: "tag-1", PeriodYear: 2026, PeriodMonth: 1, CreatedAt: "2026-01-01T00:00:00Z"}, + {Id: "exp-2", Name: "Second", TransactionAmount: 200, TagId: "tag-1", PeriodYear: 2026, PeriodMonth: 1, CreatedAt: "2026-01-02T00:00:00Z"}, + {Id: "exp-3", Name: "Third", TransactionAmount: 300, TagId: "tag-1", PeriodYear: 2026, PeriodMonth: 2, CreatedAt: "2026-02-01T00:00:00Z"}, }, } @@ -210,18 +210,18 @@ func TestExpensesProvider_Collect_CorrectedExpense(t *testing.T) { expenseClient := &mockExpenseServiceClient{ streamRows: []*expensepb.ExpenseData{ { - Id: "exp-correction", - Name: "Groceries (corrected)", - Amount: 5099, - Currency: "USD", - ExpenseType: "essentials", - TagId: "tag-1", - ExpenseDate: "2026-05-01", - PeriodYear: 2026, - PeriodMonth: 5, - Status: "corrected", - CorrectsId: "exp-original", - CreatedAt: "2026-05-02T09:00:00Z", + Id: "exp-correction", + Name: "Groceries (corrected)", + TransactionAmount: 5099, + TransactionCurrency: "USD", + ExpenseType: "essentials", + TagId: "tag-1", + ExpenseDate: "2026-05-01", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "corrected", + CorrectsId: "exp-original", + CreatedAt: "2026-05-02T09:00:00Z", }, }, } diff --git a/services/datarights/internal/engine/testdata/export/expenses.csv b/services/datarights/internal/engine/testdata/export/expenses.csv index c64d2bb5..93085ede 100644 --- a/services/datarights/internal/engine/testdata/export/expenses.csv +++ b/services/datarights/internal/engine/testdata/export/expenses.csv @@ -1,3 +1,3 @@ -id,name,amount,currency,expense_type,tag_name,expense_date,period_year,period_month,status,corrects_id,is_pro_rata,pro_rata_group,pro_rata_index,pro_rata_total,created_at +id,name,transaction_amount,transaction_currency,expense_type,tag_name,expense_date,period_year,period_month,status,corrects_id,is_pro_rata,pro_rata_group,pro_rata_index,pro_rata_total,created_at exp-1,Groceries,45.99,USD,essentials,Food,2026-05-01,2026,5,active,,false,,,,2026-05-01T12:00:00Z exp-2,Bus pass,30.00,USD,essentials,Transport,2026-05-02,2026,5,active,,false,,,,2026-05-02T09:00:00Z diff --git a/services/errkit/data.go b/services/errkit/data.go new file mode 100644 index 00000000..fdd7a5d6 --- /dev/null +++ b/services/errkit/data.go @@ -0,0 +1,35 @@ +package errkit + +import "errors" + +// DataCarrier is implemented by errors that carry structured report data. +// report() merges ReportData() into Meta.Data before logging and capturing, +// so a typed error contributes context without the call site repeating it. +type DataCarrier interface { + ReportData() map[string]any +} + +// mergeData returns the report data for err merged under caller. Caller wins on +// key collision. The caller's map is never mutated: when err carries data a new +// map is built, otherwise caller is returned unchanged so a non-carrier error +// keeps the existing behavior. +func mergeData(caller map[string]any, err error) map[string]any { + var carrier DataCarrier + if !errors.As(err, &carrier) { + return caller + } + + carried := carrier.ReportData() + if len(carried) == 0 { + return caller + } + + merged := make(map[string]any, len(carried)+len(caller)) + for key, value := range carried { + merged[key] = value + } + for key, value := range caller { + merged[key] = value + } + return merged +} diff --git a/services/errkit/data_test.go b/services/errkit/data_test.go new file mode 100644 index 00000000..edbf5f3c --- /dev/null +++ b/services/errkit/data_test.go @@ -0,0 +1,103 @@ +package errkit_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/getsentry/sentry-go" + "github.com/stretchr/testify/assert" + + "github.com/ItsThompson/gofin/services/errkit" +) + +// dataCarrierError is a typed error that carries report data, the shape a +// service error implements so a handler report does not repeat the context. +type dataCarrierError struct { + msg string + data map[string]any +} + +func (e dataCarrierError) Error() string { return e.msg } + +func (e dataCarrierError) ReportData() map[string]any { return e.data } + +var _ errkit.DataCarrier = dataCarrierError{} + +func TestReport_MergesDataCarrierDataIntoContextAndLog(t *testing.T) { + env := newReportEnv(t) + err := dataCarrierError{ + msg: "budget over limit", + data: map[string]any{"budget_id": "b-1", "attempt": 2}, + } + + _ = errkit.Report(env.ctx, err, errkit.Meta{Op: "budget.check"}) + + event := env.singleEvent(t) + assert.Equal(t, sentry.Context{"budget_id": "b-1", "attempt": 2}, event.Contexts["gofin"]) + + record := env.singleRecord(t) + assert.Equal(t, map[string]any{ + "error": "budget over limit", + "error_kind": "internal", + "operation": "budget.check", + "budget_id": "b-1", + "attempt": int64(2), + }, recordAttrs(record)) +} + +func TestReport_CallerDataWinsOverDataCarrierOnCollision(t *testing.T) { + env := newReportEnv(t) + err := dataCarrierError{ + msg: "boom", + data: map[string]any{"expense_id": "from-error", "shared": "error"}, + } + + _ = errkit.Report(env.ctx, err, errkit.Meta{ + Op: "expense.create", + Data: map[string]any{"expense_id": "from-caller", "caller_only": true}, + }) + + event := env.singleEvent(t) + assert.Equal(t, sentry.Context{ + "expense_id": "from-caller", + "shared": "error", + "caller_only": true, + }, event.Contexts["gofin"]) + + record := env.singleRecord(t) + attrs := recordAttrs(record) + assert.Equal(t, "from-caller", attrs["expense_id"]) + assert.Equal(t, "error", attrs["shared"]) + assert.Equal(t, true, attrs["caller_only"]) +} + +func TestReport_FindsDataCarrierThroughWrapChain(t *testing.T) { + env := newReportEnv(t) + carrier := dataCarrierError{msg: "budget over limit", data: map[string]any{"budget_id": "b-1"}} + err := fmt.Errorf("check budget: %w", carrier) + + _ = errkit.Report(env.ctx, err, errkit.Meta{Op: "budget.check"}) + + event := env.singleEvent(t) + assert.Equal(t, sentry.Context{"budget_id": "b-1"}, event.Contexts["gofin"]) + + record := env.singleRecord(t) + assert.Equal(t, "b-1", recordAttrs(record)["budget_id"]) +} + +func TestReport_NonDataCarrierLeavesDataEmpty(t *testing.T) { + env := newReportEnv(t) + + _ = errkit.Report(env.ctx, errors.New("boom"), errkit.Meta{Op: "expense.create"}) + + event := env.singleEvent(t) + assert.NotContains(t, event.Contexts, "gofin") + + record := env.singleRecord(t) + assert.Equal(t, map[string]any{ + "error": "boom", + "error_kind": "internal", + "operation": "expense.create", + }, recordAttrs(record)) +} diff --git a/services/errkit/report.go b/services/errkit/report.go index e52a7b07..52225ceb 100644 --- a/services/errkit/report.go +++ b/services/errkit/report.go @@ -43,6 +43,7 @@ func report(ctx context.Context, err error, m Meta, skip int) error { } level := m.level() + m.Data = mergeData(m.Data, err) // Logged before the hub is resolved and unconditionally, because the log // record is the durable artifact: it must not depend on a DSN being set. diff --git a/services/expense/Dockerfile b/services/expense/Dockerfile index c804b089..97eaa199 100644 --- a/services/expense/Dockerfile +++ b/services/expense/Dockerfile @@ -17,6 +17,7 @@ COPY apierr/go.mod apierr/go.sum* ./apierr/ COPY errkit/go.mod errkit/go.sum* ./errkit/ COPY httpx/go.mod httpx/go.sum* ./httpx/ COPY finance/go.mod finance/go.sum* ./finance/ +COPY fx/go.mod fx/go.sum* ./fx/ COPY shared/currency/go.mod ./shared/currency/ RUN --mount=type=cache,target=/go/pkg/mod \ cd expense && GOWORK=off go mod download @@ -32,6 +33,7 @@ COPY apierr/ ./apierr/ COPY errkit/ ./errkit/ COPY httpx/ ./httpx/ COPY finance/proto/ ./finance/proto/ +COPY fx/proto/ ./fx/proto/ COPY shared/currency/ ./shared/currency/ RUN --mount=type=cache,target=/go/pkg/mod \ diff --git a/services/expense/cmd/immudb_local.go b/services/expense/cmd/immudb_local.go index b7544be1..429c25ee 100644 --- a/services/expense/cmd/immudb_local.go +++ b/services/expense/cmd/immudb_local.go @@ -189,7 +189,7 @@ func toInt32(v interface{}) int32 { // column order used by the SELECT queries in the repository. func rowToSQLRow(row map[string]interface{}) repository.SQLRow { columns := []string{ - "id", "user_id", "name", "amount", "currency", "expense_type", "tag_id", + "id", "user_id", "name", "expense_type", "tag_id", "expense_date", "period_year", "period_month", "status", "corrects_id", "is_pro_rata", "pro_rata_group", "pro_rata_index", "pro_rata_total", "created_at", "transaction_amount", "transaction_currency", diff --git a/services/expense/cmd/main.go b/services/expense/cmd/main.go index 8bbd6a58..f7238e82 100644 --- a/services/expense/cmd/main.go +++ b/services/expense/cmd/main.go @@ -85,7 +85,17 @@ func run() error { ) periodClient := service.NewGRPCPeriodContextClient(financepb.NewFinanceServiceClient(financeConn)) - expenseSvc := service.NewExpenseService(repo, periodClient, time.Now, logger) + + fxClient, fxConn, err := service.NewGRPCFxClientFromAddr(cfg.FxServiceAddr) + if err != nil { + return fmt.Errorf("creating fx service client for %s: %w", cfg.FxServiceAddr, err) + } + defer func() { _ = fxConn.Close() }() + logger.Info("fx service gRPC client created", + slog.String("addr", cfg.FxServiceAddr), + ) + + expenseSvc := service.NewExpenseService(repo, periodClient, fxClient, time.Now, logger) // Build the gRPC server and pre-bind its listener so a bind failure surfaces. grpcServer := serverkit.NewGRPCServer() diff --git a/services/expense/go.mod b/services/expense/go.mod index 8e009fe6..9d5d0f46 100644 --- a/services/expense/go.mod +++ b/services/expense/go.mod @@ -7,6 +7,7 @@ require ( github.com/ItsThompson/gofin/services/apierr v0.0.0 github.com/ItsThompson/gofin/services/errkit v0.0.0 github.com/ItsThompson/gofin/services/finance v0.0.0-00010101000000-000000000000 + github.com/ItsThompson/gofin/services/fx v0.0.0 github.com/ItsThompson/gofin/services/healthcheck v0.0.0 github.com/ItsThompson/gofin/services/httpx v0.0.0 github.com/ItsThompson/gofin/services/metrics v0.0.0 @@ -125,3 +126,5 @@ replace github.com/ItsThompson/gofin/services/finance => ../finance replace github.com/ItsThompson/gofin/services/shared/currency => ../shared/currency replace github.com/ItsThompson/gofin/services/errkit => ../errkit + +replace github.com/ItsThompson/gofin/services/fx => ../fx diff --git a/services/expense/internal/config/config.go b/services/expense/internal/config/config.go index bb9ba6e1..8cfae329 100644 --- a/services/expense/internal/config/config.go +++ b/services/expense/internal/config/config.go @@ -26,6 +26,7 @@ type Config struct { ImmudbUsername string ImmudbPassword string FinanceServiceAddr string + FxServiceAddr string LogLevel string Environment string RESTPort string @@ -55,6 +56,11 @@ func Load() (*Config, error) { return nil, fmt.Errorf("FINANCE_SERVICE_ADDR is required") } + fxServiceAddr := os.Getenv("FX_SERVICE_ADDR") + if fxServiceAddr == "" { + return nil, fmt.Errorf("FX_SERVICE_ADDR is required") + } + logLevel := os.Getenv("LOG_LEVEL") if logLevel == "" { logLevel = "info" @@ -77,6 +83,7 @@ func Load() (*Config, error) { ImmudbUsername: immudbUsername, ImmudbPassword: immudbPassword, FinanceServiceAddr: financeServiceAddr, + FxServiceAddr: fxServiceAddr, LogLevel: logLevel, Environment: environment, RESTPort: restPort, diff --git a/services/expense/internal/config/config_test.go b/services/expense/internal/config/config_test.go index e1cbec7a..f270233d 100644 --- a/services/expense/internal/config/config_test.go +++ b/services/expense/internal/config/config_test.go @@ -13,6 +13,7 @@ func TestLoad_Success(t *testing.T) { t.Setenv("IMMUDB_USERNAME", "testuser") t.Setenv("IMMUDB_PASSWORD", "testpass") t.Setenv("FINANCE_SERVICE_ADDR", "localhost:9083") + t.Setenv("FX_SERVICE_ADDR", "localhost:9085") cfg, err := Load() @@ -21,6 +22,7 @@ func TestLoad_Success(t *testing.T) { assert.Equal(t, "testuser", cfg.ImmudbUsername) assert.Equal(t, "testpass", cfg.ImmudbPassword) assert.Equal(t, "localhost:9083", cfg.FinanceServiceAddr) + assert.Equal(t, "localhost:9085", cfg.FxServiceAddr) assert.Equal(t, "8082", cfg.RESTPort) assert.Equal(t, "9082", cfg.GRPCPort) assert.Equal(t, "info", cfg.LogLevel) @@ -40,6 +42,7 @@ func TestLoad_RequiresImmudbAddr(t *testing.T) { func TestLoad_RequiresFinanceServiceAddr(t *testing.T) { t.Setenv("IMMUDB_ADDR", "localhost:3322") _ = os.Unsetenv("FINANCE_SERVICE_ADDR") + t.Setenv("FX_SERVICE_ADDR", "localhost:9085") _, err := Load() @@ -47,9 +50,21 @@ func TestLoad_RequiresFinanceServiceAddr(t *testing.T) { assert.Contains(t, err.Error(), "FINANCE_SERVICE_ADDR is required") } +func TestLoad_RequiresFxServiceAddr(t *testing.T) { + t.Setenv("IMMUDB_ADDR", "localhost:3322") + t.Setenv("FINANCE_SERVICE_ADDR", "localhost:9083") + _ = os.Unsetenv("FX_SERVICE_ADDR") + + _, err := Load() + + require.Error(t, err) + assert.Contains(t, err.Error(), "FX_SERVICE_ADDR is required") +} + func TestLoad_Defaults(t *testing.T) { t.Setenv("IMMUDB_ADDR", "localhost:3322") t.Setenv("FINANCE_SERVICE_ADDR", "localhost:9083") + t.Setenv("FX_SERVICE_ADDR", "localhost:9085") // Leave username/password unset to test defaults _ = os.Unsetenv("IMMUDB_USERNAME") _ = os.Unsetenv("IMMUDB_PASSWORD") @@ -64,6 +79,7 @@ func TestLoad_Defaults(t *testing.T) { func TestLoad_CustomPorts(t *testing.T) { t.Setenv("IMMUDB_ADDR", "localhost:3322") t.Setenv("FINANCE_SERVICE_ADDR", "localhost:9083") + t.Setenv("FX_SERVICE_ADDR", "localhost:9085") t.Setenv("REST_PORT", "9999") t.Setenv("GRPC_PORT", "9998") @@ -89,6 +105,7 @@ func TestResolveRESTPort(t *testing.T) { func TestIsProduction(t *testing.T) { t.Setenv("IMMUDB_ADDR", "localhost:3322") t.Setenv("FINANCE_SERVICE_ADDR", "localhost:9083") + t.Setenv("FX_SERVICE_ADDR", "localhost:9085") t.Setenv("ENVIRONMENT", "development") cfg, _ := Load() diff --git a/services/expense/internal/handler/grpc.go b/services/expense/internal/handler/grpc.go index 78cb1818..cda02234 100644 --- a/services/expense/internal/handler/grpc.go +++ b/services/expense/internal/handler/grpc.go @@ -196,8 +196,6 @@ func expenseToProto(e *model.Expense) *pb.ExpenseData { Id: e.ID, UserId: e.UserID, Name: e.Name, - Amount: e.Amount, - Currency: e.Currency, TransactionCurrency: e.TransactionCurrency, ExpenseType: e.ExpenseType, TagId: e.TagID, diff --git a/services/expense/internal/handler/grpc_internal_logging_test.go b/services/expense/internal/handler/grpc_internal_logging_test.go index c6b0e573..ba2bdb90 100644 --- a/services/expense/internal/handler/grpc_internal_logging_test.go +++ b/services/expense/internal/handler/grpc_internal_logging_test.go @@ -41,7 +41,7 @@ func newGRPCHandlerWithLog(t *testing.T, repo *mockExpenseRepository) (*GRPCHand slog.SetDefault(logger) t.Cleanup(func() { slog.SetDefault(previous) }) - expenseSvc := service.NewExpenseService(repo, newTestPeriodClient(), time.Now, logger) + expenseSvc := service.NewExpenseService(repo, newTestPeriodClient(), &stubFxClient{}, time.Now, logger) return NewGRPCHandler(expenseSvc), buf } diff --git a/services/expense/internal/handler/grpc_stream_test.go b/services/expense/internal/handler/grpc_stream_test.go index 447d5364..806ff043 100644 --- a/services/expense/internal/handler/grpc_stream_test.go +++ b/services/expense/internal/handler/grpc_stream_test.go @@ -49,8 +49,6 @@ func streamRow(id, createdAt string) *model.Expense { ID: id, UserID: "user-1", Name: "Expense " + id, - Amount: 2500, - Currency: "USD", ExpenseType: "essentials", TagID: "tag-1", ExpenseDate: "2026-05-01", @@ -76,7 +74,6 @@ func TestGRPC_StreamAllUserExpenses_SendsAllRowsAsProto(t *testing.T) { require.Len(t, stream.sent, 2) assert.Equal(t, "exp-1", stream.sent[0].GetId()) assert.Equal(t, "exp-2", stream.sent[1].GetId()) - assert.Equal(t, int64(2500), stream.sent[0].GetAmount()) assert.Equal(t, "2026-05-01T00:00:01Z", stream.sent[1].GetCreatedAt()) repo.AssertExpectations(t) } diff --git a/services/expense/internal/handler/grpc_test.go b/services/expense/internal/handler/grpc_test.go index 03fde17c..c5d58be7 100644 --- a/services/expense/internal/handler/grpc_test.go +++ b/services/expense/internal/handler/grpc_test.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "log/slog" + "net/http" "testing" "time" @@ -22,10 +23,31 @@ import ( func newTestGRPCHandler(repo *mockExpenseRepository) *GRPCHandler { logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - expenseSvc := service.NewExpenseService(repo, newTestPeriodClient(), time.Now, logger) + expenseSvc := service.NewExpenseService(repo, newTestPeriodClient(), &stubFxClient{}, time.Now, logger) return NewGRPCHandler(expenseSvc) } +// mockFxClient implements service.FxClient for handler tests. +type mockFxClient struct { + mock.Mock +} + +func (m *mockFxClient) ConvertAmount(ctx context.Context, req service.FxConvertRequest) (*service.FxConvertResponse, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*service.FxConvertResponse), args.Error(1) +} + +// stubFxClient is a non-nil FxClient for handler tests that never exercise FX. +// A call to it means a test accidentally routed a foreign-currency request. +type stubFxClient struct{} + +func (stubFxClient) ConvertAmount(ctx context.Context, req service.FxConvertRequest) (*service.FxConvertResponse, error) { + return nil, fmt.Errorf("stubFxClient: unexpected ConvertAmount call") +} + // TestGRPC_RemovedReadRPCsAreNotRegistered asserts GetCorrectionHistory and // GetProRataGroup are served over REST, not gRPC. This guards against // re-introducing an unscoped read RPC on the gRPC surface. @@ -62,17 +84,15 @@ func TestGRPC_CreateExpense_UsesTransactionCurrency(t *testing.T) { ReportingCurrency: "EUR", }, nil) logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - expenseSvc := service.NewExpenseService(repo, periodClient, time.Now, logger) + expenseSvc := service.NewExpenseService(repo, periodClient, &stubFxClient{}, time.Now, logger) handler := NewGRPCHandler(expenseSvc) repo.On("CreateExpense", mock.Anything, mock.MatchedBy(func(expense *model.Expense) bool { - return expense.TransactionCurrency == "EUR" && expense.Currency == "EUR" + return expense.TransactionCurrency == "EUR" })).Return(&model.Expense{ ID: "exp-1", UserID: "user-1", - Amount: 1200, TransactionCurrency: "EUR", - Currency: "EUR", Status: "active", TransactionAmount: 1200, ReportingAmount: 1200, @@ -109,7 +129,7 @@ func TestGRPC_CreateExpense_MissingPeriodReturnsNotFoundWithYearMonth(t *testing repo := new(mockExpenseRepository) periodClient := new(mockPeriodContextClient) logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - expenseSvc := service.NewExpenseService(repo, periodClient, time.Now, logger) + expenseSvc := service.NewExpenseService(repo, periodClient, &stubFxClient{}, time.Now, logger) handler := NewGRPCHandler(expenseSvc) periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(6)). @@ -124,7 +144,6 @@ func TestGRPC_CreateExpense_MissingPeriodReturnsNotFoundWithYearMonth(t *testing UserId: "user-1", Name: "Coffee", Amount: 450, - Currency: "USD", ExpenseType: "desires", TagId: "tag-food", ExpenseDate: "2026-06-03", @@ -141,12 +160,17 @@ func TestGRPC_CreateExpense_MissingPeriodReturnsNotFoundWithYearMonth(t *testing repo.AssertNotCalled(t, "CreateExpense", mock.Anything, mock.Anything) } -// TestGRPC_CreateExpense_ForeignCurrencyReturnsUnavailable asserts a -// transaction currency that differs from the period reporting currency maps -// to codes.Unavailable (FX not yet wired) and does not write a ledger row. -func TestGRPC_CreateExpense_ForeignCurrencyReturnsUnavailable(t *testing.T) { +// TestGRPC_CreateExpense_ForeignCurrencyFxSuccess asserts a foreign-currency +// expense with a wired FX client calls FX, writes the provider snapshot, and +// returns both transaction and reporting money fields in the gRPC response. +func TestGRPC_CreateExpense_ForeignCurrencyFxSuccess(t *testing.T) { repo := new(mockExpenseRepository) periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + + now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + requestedAt := now.UTC().Format(time.RFC3339) + periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(5)).Return(&service.PeriodContext{ PeriodID: "period-1", UserID: "user-1", @@ -154,14 +178,99 @@ func TestGRPC_CreateExpense_ForeignCurrencyReturnsUnavailable(t *testing.T) { Month: 5, ReportingCurrency: "USD", }, nil) + + fxClient.On("ConvertAmount", mock.Anything, mock.MatchedBy(func(req service.FxConvertRequest) bool { + return req.Amount == 1250 && + req.SourceCurrency == "EUR" && + req.TargetCurrency == "USD" && + req.RequestedAt == requestedAt + })).Return(&service.FxConvertResponse{ + ConvertedAmount: 1364, + ExchangeRate: "1.0912", + RateTimestamp: "2026-08-14T10:00:00Z", + Source: model.ExchangeSourceOpenExchangeRates, + ExpiresAt: "2026-08-14T11:00:00Z", + }, nil) + + repo.On("CreateExpense", mock.Anything, mock.Anything).Return(&model.Expense{ + ID: "exp-fx-1", + UserID: "user-1", + Name: "Cafe", + TransactionCurrency: "EUR", + ExpenseType: "desires", + TagID: "tag-food", + ExpenseDate: "2026-05-03", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + TransactionAmount: 1250, + ReportingAmount: 1364, + ReportingCurrency: "USD", + ExchangeRate: "1.0912", + ExchangeRateSource: model.ExchangeSourceOpenExchangeRates, + ExchangeRateTimestamp: "2026-08-14T10:00:00Z", + ExchangeRateExpiresAt: "2026-08-14T11:00:00Z", + }, nil) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - expenseSvc := service.NewExpenseService(repo, periodClient, time.Now, logger) + expenseSvc := service.NewExpenseService(repo, periodClient, fxClient, func() time.Time { return now }, logger) handler := NewGRPCHandler(expenseSvc) resp, err := handler.CreateExpense(context.Background(), &pb.CreateExpenseRequest{ UserId: "user-1", - Name: "Coffee", - Amount: 450, + Name: "Cafe", + Amount: 1250, + TransactionCurrency: "EUR", + ExpenseType: "desires", + TagId: "tag-food", + ExpenseDate: "2026-05-03", + PeriodYear: 2026, + PeriodMonth: 5, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + + exp := resp.GetExpense() + assert.Equal(t, int64(1250), exp.GetTransactionAmount()) + assert.Equal(t, "EUR", exp.GetTransactionCurrency()) + assert.Equal(t, int64(1364), exp.GetReportingAmount()) + assert.Equal(t, "USD", exp.GetReportingCurrency()) + assert.Equal(t, "1.0912", exp.GetExchangeRate()) + assert.Equal(t, "open_exchange_rates", exp.GetExchangeRateSource()) + assert.Equal(t, "2026-08-14T10:00:00Z", exp.GetExchangeRateTimestamp()) + assert.Equal(t, "2026-08-14T11:00:00Z", exp.GetExchangeRateExpiresAt()) +} + +// TestGRPC_CreateExpense_ForeignCurrencyFxUnavailable asserts a foreign-currency +// expense with an FX failure maps to codes.Unavailable and does not write a ledger row. +func TestGRPC_CreateExpense_ForeignCurrencyFxUnavailable(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + + periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(5)).Return(&service.PeriodContext{ + PeriodID: "period-1", + UserID: "user-1", + Year: 2026, + Month: 5, + ReportingCurrency: "USD", + }, nil) + + fxClient.On("ConvertAmount", mock.Anything, mock.Anything).Return(nil, &apierr.Error{ + Code: model.ErrConversionUnavailable, + Message: "currency conversion is unavailable", + Status: http.StatusServiceUnavailable, + }) + + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + expenseSvc := service.NewExpenseService(repo, periodClient, fxClient, time.Now, logger) + handler := NewGRPCHandler(expenseSvc) + + resp, err := handler.CreateExpense(context.Background(), &pb.CreateExpenseRequest{ + UserId: "user-1", + Name: "Cafe", + Amount: 1250, TransactionCurrency: "EUR", ExpenseType: "desires", TagId: "tag-food", diff --git a/services/expense/internal/handler/rest_report_test.go b/services/expense/internal/handler/rest_report_test.go index 22b871a2..d817d768 100644 --- a/services/expense/internal/handler/rest_report_test.go +++ b/services/expense/internal/handler/rest_report_test.go @@ -44,7 +44,7 @@ func newReportingRouter(t *testing.T, repo *mockExpenseRepository) (*gin.Engine, c.Request = c.Request.WithContext(errkittest.ContextWithHub(c.Request.Context(), transport)) c.Next() }) - NewRESTHandler(service.NewExpenseService(repo, newTestPeriodClient(), time.Now, logger)).RegisterRoutes(engine) + NewRESTHandler(service.NewExpenseService(repo, newTestPeriodClient(), &stubFxClient{}, time.Now, logger)).RegisterRoutes(engine) return engine, transport, buf } diff --git a/services/expense/internal/handler/rest_test.go b/services/expense/internal/handler/rest_test.go index 3c65bb40..c1034cff 100644 --- a/services/expense/internal/handler/rest_test.go +++ b/services/expense/internal/handler/rest_test.go @@ -109,9 +109,21 @@ func setupTestRouter(repo *mockExpenseRepository) *gin.Engine { // setupTestRouterWithPeriod builds a router wired to a custom period context // client, so handler tests can exercise non-default reporting currencies. func setupTestRouterWithPeriod(repo *mockExpenseRepository, periodClient *mockPeriodContextClient) *gin.Engine { + return setupTestRouterWithFx(repo, periodClient, &stubFxClient{}) +} + +// setupTestRouterWithFx builds a router wired to a custom period context client +// and FX client, so handler tests can exercise foreign-currency conversion. +func setupTestRouterWithFx(repo *mockExpenseRepository, periodClient *mockPeriodContextClient, fxClient service.FxClient) *gin.Engine { + return setupTestRouterWithFxClock(repo, periodClient, fxClient, time.Now) +} + +// setupTestRouterWithFxClock builds a router with a fixed clock so FX tests can +// assert the exact request (including RequestedAt). +func setupTestRouterWithFxClock(repo *mockExpenseRepository, periodClient *mockPeriodContextClient, fxClient service.FxClient, clock func() time.Time) *gin.Engine { gin.SetMode(gin.TestMode) logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - expenseSvc := service.NewExpenseService(repo, periodClient, time.Now, logger) + expenseSvc := service.NewExpenseService(repo, periodClient, fxClient, clock, logger) h := NewRESTHandler(expenseSvc) r := gin.New() h.RegisterRoutes(r) @@ -141,18 +153,17 @@ func TestCreateExpenseHandler_Success(t *testing.T) { repo.On("CreateExpense", mock.Anything, mock.AnythingOfType("*model.Expense")). Return(&model.Expense{ - ID: "exp-123", - UserID: "user-1", - Name: "Grocery shopping", - Amount: 2500, - Currency: "USD", - ExpenseType: "essentials", - TagID: "tag-food", - ExpenseDate: "2026-05-03", - PeriodYear: 2026, - PeriodMonth: 5, - Status: "active", - CreatedAt: "2026-05-03T10:00:00Z", + ID: "exp-123", + UserID: "user-1", + Name: "Grocery shopping", + ExpenseType: "essentials", + TagID: "tag-food", + ExpenseDate: "2026-05-03", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + CreatedAt: "2026-05-03T10:00:00Z", + TransactionAmount: 2500, }, nil) r := setupTestRouter(repo) @@ -173,7 +184,7 @@ func TestCreateExpenseHandler_Success(t *testing.T) { require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(t, "exp-123", resp.Expense.ID) assert.Equal(t, "user-1", resp.Expense.UserID) - assert.Equal(t, int64(2500), resp.Expense.Amount) + assert.Equal(t, int64(2500), resp.Expense.TransactionAmount) assert.Equal(t, "essentials", resp.Expense.ExpenseType) assert.Equal(t, "active", resp.Expense.Status) } @@ -191,14 +202,12 @@ func TestCreateExpenseHandler_AcceptsTransactionCurrency(t *testing.T) { }, nil) repo.On("CreateExpense", mock.Anything, mock.MatchedBy(func(expense *model.Expense) bool { - return expense.TransactionCurrency == "EUR" && expense.Currency == "EUR" + return expense.TransactionCurrency == "EUR" })).Return(&model.Expense{ ID: "exp-123", UserID: "user-1", Name: "Coffee", - Amount: 450, TransactionCurrency: "EUR", - Currency: "EUR", ExpenseType: "desires", TagID: "tag-food", ExpenseDate: "2026-05-03", @@ -231,7 +240,6 @@ func TestCreateExpenseHandler_AcceptsTransactionCurrency(t *testing.T) { var resp model.ExpenseResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(t, "EUR", resp.Expense.TransactionCurrency) - assert.Equal(t, "EUR", resp.Expense.Currency) // Canonical transaction and reporting money fields are present in the response. assert.Equal(t, int64(450), resp.Expense.TransactionAmount) assert.Equal(t, int64(450), resp.Expense.ReportingAmount) @@ -241,12 +249,16 @@ func TestCreateExpenseHandler_AcceptsTransactionCurrency(t *testing.T) { repo.AssertExpectations(t) } -// TestCreateExpenseHandler_ForeignCurrencyReturnsServiceUnavailable asserts a -// transaction currency that differs from the period reporting currency maps -// to HTTP 503 CONVERSION_UNAVAILABLE and does not write a ledger row. -func TestCreateExpenseHandler_ForeignCurrencyReturnsServiceUnavailable(t *testing.T) { +// TestCreateExpenseHandler_ForeignCurrencyFxSuccess asserts a foreign-currency +// expense with a wired FX client returns 201 with both transaction and reporting +// money fields plus snapshot metadata. +func TestCreateExpenseHandler_ForeignCurrencyFxSuccess(t *testing.T) { repo := new(mockExpenseRepository) periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + requestedAt := now.UTC().Format(time.RFC3339) + periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(5)).Return(&service.PeriodContext{ PeriodID: "period-1", UserID: "user-1", @@ -255,11 +267,92 @@ func TestCreateExpenseHandler_ForeignCurrencyReturnsServiceUnavailable(t *testin ReportingCurrency: "USD", }, nil) - r := setupTestRouterWithPeriod(repo, periodClient) + fxClient.On("ConvertAmount", mock.Anything, mock.MatchedBy(func(req service.FxConvertRequest) bool { + return req.Amount == 1250 && + req.SourceCurrency == "EUR" && + req.TargetCurrency == "USD" && + req.RequestedAt == requestedAt + })).Return(&service.FxConvertResponse{ + ConvertedAmount: 1364, + ExchangeRate: "1.0912", + RateTimestamp: "2026-08-14T10:00:00Z", + Source: model.ExchangeSourceOpenExchangeRates, + ExpiresAt: "2026-08-14T11:00:00Z", + }, nil) + + repo.On("CreateExpense", mock.Anything, mock.Anything).Return(&model.Expense{ + ID: "exp-fx-1", + UserID: "user-1", + Name: "Cafe", + TransactionCurrency: "EUR", + ExpenseType: "desires", + TagID: "tag-food", + ExpenseDate: "2026-05-03", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + TransactionAmount: 1250, + ReportingAmount: 1364, + ReportingCurrency: "USD", + ExchangeRate: "1.0912", + ExchangeRateSource: model.ExchangeSourceOpenExchangeRates, + ExchangeRateTimestamp: "2026-08-14T10:00:00Z", + ExchangeRateExpiresAt: "2026-08-14T11:00:00Z", + }, nil) + + r := setupTestRouterWithFxClock(repo, periodClient, fxClient, func() time.Time { return now }) w := doJSONWithUserID(r, "POST", "/api/expenses", "user-1", map[string]interface{}{ - "name": "Coffee", - "amount": 450, + "name": "Cafe", + "amount": 1250, + "transactionCurrency": "EUR", + "expenseType": "desires", + "tagId": "tag-food", + "expenseDate": "2026-05-03", + "periodYear": 2026, + "periodMonth": 5, + }) + + assert.Equal(t, http.StatusCreated, w.Code) + + var resp model.ExpenseResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, int64(1250), resp.Expense.TransactionAmount) + assert.Equal(t, "EUR", resp.Expense.TransactionCurrency) + assert.Equal(t, int64(1364), resp.Expense.ReportingAmount) + assert.Equal(t, "USD", resp.Expense.ReportingCurrency) + assert.Equal(t, "1.0912", resp.Expense.ExchangeRate) + assert.Equal(t, "open_exchange_rates", resp.Expense.ExchangeRateSource) + assert.Equal(t, "2026-08-14T10:00:00Z", resp.Expense.ExchangeRateTimestamp) + assert.Equal(t, "2026-08-14T11:00:00Z", resp.Expense.ExchangeRateExpiresAt) +} + +// TestCreateExpenseHandler_ForeignCurrencyFxUnavailable asserts a foreign-currency +// expense with an FX failure returns 503 CONVERSION_UNAVAILABLE and does not write. +func TestCreateExpenseHandler_ForeignCurrencyFxUnavailable(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + + periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(5)).Return(&service.PeriodContext{ + PeriodID: "period-1", + UserID: "user-1", + Year: 2026, + Month: 5, + ReportingCurrency: "USD", + }, nil) + + fxClient.On("ConvertAmount", mock.Anything, mock.Anything).Return(nil, &apierr.Error{ + Code: model.ErrConversionUnavailable, + Message: "currency conversion is unavailable", + Status: http.StatusServiceUnavailable, + }) + + r := setupTestRouterWithFx(repo, periodClient, fxClient) + + w := doJSONWithUserID(r, "POST", "/api/expenses", "user-1", map[string]interface{}{ + "name": "Cafe", + "amount": 1250, "transactionCurrency": "EUR", "expenseType": "desires", "tagId": "tag-food", @@ -386,8 +479,8 @@ func TestGetExpensesHandler_Success(t *testing.T) { repo := new(mockExpenseRepository) expenses := []*model.Expense{ - {ID: "exp-1", Name: "Groceries", Amount: 5000, Status: "active", ExpenseDate: "2026-05-02"}, - {ID: "exp-2", Name: "Coffee", Amount: 500, Status: "active", ExpenseDate: "2026-05-01"}, + {ID: "exp-1", Name: "Groceries", Status: "active", ExpenseDate: "2026-05-02"}, + {ID: "exp-2", Name: "Coffee", Status: "active", ExpenseDate: "2026-05-01"}, } repo.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(5), int32(1), int32(50)). @@ -412,7 +505,7 @@ func TestGetExpensesHandler_WithPagination(t *testing.T) { repo := new(mockExpenseRepository) expenses := []*model.Expense{ - {ID: "exp-3", Name: "Lunch", Amount: 1500, Status: "active"}, + {ID: "exp-3", Name: "Lunch", Status: "active"}, } repo.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(5), int32(2), int32(10)). @@ -483,8 +576,6 @@ func TestGetExpenseHandler_Success(t *testing.T) { ID: "exp-123", UserID: "user-1", Name: "Coffee", - Amount: 500, - Currency: "USD", ExpenseType: "desires", Status: "active", }, nil) @@ -523,7 +614,7 @@ func TestGetExpenseHandler_NotFound(t *testing.T) { func setupTestRouterWithClock(repo *mockExpenseRepository, now time.Time) *gin.Engine { gin.SetMode(gin.TestMode) logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - expenseSvc := service.NewExpenseService(repo, newTestPeriodClient(), func() time.Time { return now }, logger) + expenseSvc := service.NewExpenseService(repo, newTestPeriodClient(), &stubFxClient{}, func() time.Time { return now }, logger) h := NewRESTHandler(expenseSvc) r := gin.New() h.RegisterRoutes(r) @@ -536,8 +627,8 @@ func TestCorrectExpenseHandler_Success(t *testing.T) { original := &model.Expense{ ID: "exp-original", UserID: "user-1", Name: "Coffee", - Amount: 500, Currency: "USD", ExpenseType: "desires", - TagID: "tag-food", ExpenseDate: "2026-05-01", + ExpenseType: "desires", + TagID: "tag-food", ExpenseDate: "2026-05-01", PeriodYear: 2026, PeriodMonth: 5, Status: "active", CreatedAt: "2026-05-01T10:00:00Z", } @@ -546,8 +637,8 @@ func TestCorrectExpenseHandler_Success(t *testing.T) { repo.On("CorrectExpense", mock.Anything, original, mock.AnythingOfType("*model.Expense")). Return(&model.Expense{ ID: "exp-correction", UserID: "user-1", Name: "Updated Coffee", - Amount: 600, Currency: "USD", ExpenseType: "desires", - TagID: "tag-food", ExpenseDate: "2026-05-01", + ExpenseType: "desires", + TagID: "tag-food", ExpenseDate: "2026-05-01", PeriodYear: 2026, PeriodMonth: 5, Status: "active", CorrectsID: "exp-original", CreatedAt: "2026-05-03T10:00:00Z", }, nil) @@ -572,8 +663,8 @@ func TestCorrectExpenseHandler_AlreadyCorrected(t *testing.T) { corrected := &model.Expense{ ID: "exp-original", UserID: "user-1", Name: "Coffee", - Amount: 500, Currency: "USD", ExpenseType: "desires", - TagID: "tag-food", ExpenseDate: "2026-05-01", + ExpenseType: "desires", + TagID: "tag-food", ExpenseDate: "2026-05-01", PeriodYear: 2026, PeriodMonth: 5, Status: "corrected", } repo.On("GetExpenseByID", mock.Anything, "exp-original", "user-1").Return(corrected, nil) @@ -596,8 +687,8 @@ func TestCorrectExpenseHandler_PeriodLocked(t *testing.T) { pastExpense := &model.Expense{ ID: "exp-past", UserID: "user-1", Name: "Old Coffee", - Amount: 500, Currency: "USD", ExpenseType: "desires", - TagID: "tag-food", ExpenseDate: "2026-04-15", + ExpenseType: "desires", + TagID: "tag-food", ExpenseDate: "2026-04-15", PeriodYear: 2026, PeriodMonth: 4, Status: "active", } repo.On("GetExpenseByID", mock.Anything, "exp-past", "user-1").Return(pastExpense, nil) diff --git a/services/expense/internal/handler/suggestions_test.go b/services/expense/internal/handler/suggestions_test.go index 17bdb528..31eba753 100644 --- a/services/expense/internal/handler/suggestions_test.go +++ b/services/expense/internal/handler/suggestions_test.go @@ -30,7 +30,7 @@ func TestGetExpenseSuggestionsHandler_DefaultsPaginationAndReturnsShape(t *testi repo := new(mockExpenseRepository) r := setupTestRouter(repo) repo.On("GetActiveExpenseSuggestionInputs", mock.Anything, "user-1").Return([]*model.ExpenseSuggestionInput{ - {ID: "exp-1", Name: "Groceries", Amount: 2500, Currency: "USD", ExpenseType: "essentials", TagID: "tag-food", CreatedAt: "2026-05-31T10:00:00Z"}, + {ID: "exp-1", Name: "Groceries", TransactionAmount: 2500, TransactionCurrency: "USD", ExpenseType: "essentials", TagID: "tag-food", CreatedAt: "2026-05-31T10:00:00Z"}, }, nil) w := doJSONWithUserID(r, http.MethodGet, "/api/expenses/suggestions", "user-1", nil) @@ -44,7 +44,8 @@ func TestGetExpenseSuggestionsHandler_DefaultsPaginationAndReturnsShape(t *testi assert.False(t, response.HasMore) require.Len(t, response.Data, 1) assert.Equal(t, "Groceries", response.Data[0].Name) - assert.Equal(t, int64(2500), response.Data[0].Amount) + assert.Equal(t, int64(2500), response.Data[0].TransactionAmount) + assert.Equal(t, "USD", response.Data[0].TransactionCurrency) } func TestGetExpenseSuggestionsHandler_RejectsInvalidPagination(t *testing.T) { diff --git a/services/expense/internal/model/expense.go b/services/expense/internal/model/expense.go index fbef70e9..ae0e6ec7 100644 --- a/services/expense/internal/model/expense.go +++ b/services/expense/internal/model/expense.go @@ -12,9 +12,7 @@ type Expense struct { ID string `json:"id"` UserID string `json:"userId"` Name string `json:"name"` - Amount int64 `json:"amount"` // Legacy minor-units column; equals TransactionAmount for new same-currency rows. TransactionCurrency string `json:"transactionCurrency"` - Currency string `json:"currency"` // Legacy ISO 4217 column; mirrors TransactionCurrency during rollout. ExpenseType string `json:"expenseType"` // "essentials", "desires", "savings" TagID string `json:"tagId"` ExpenseDate string `json:"expenseDate"` // ISO date: "2026-05-03" diff --git a/services/expense/internal/model/suggestions.go b/services/expense/internal/model/suggestions.go index 5e50d871..12d13ff4 100644 --- a/services/expense/internal/model/suggestions.go +++ b/services/expense/internal/model/suggestions.go @@ -8,30 +8,34 @@ type ExpenseSuggestionRequest struct { } // ExpenseSuggestionInput is the minimal active row data required to build suggestions. +// TransactionAmount/TransactionCurrency come from the explicit money snapshot +// columns, which the mc/03 migration backfilled for every row. type ExpenseSuggestionInput struct { - ID string - Name string - Amount int64 - Currency string - ExpenseType string - TagID string - CreatedAt string - ExpenseDate string - IsProRata bool - ProRataGroup string + ID string + Name string + TransactionAmount int64 // Original transaction amount in minor units from the money snapshot. + TransactionCurrency string // Original transaction currency from the money snapshot. + ExpenseType string + TagID string + CreatedAt string + ExpenseDate string + IsProRata bool + ProRataGroup string } // ExpenseSuggestion is one aggregated exact-name suggestion. +// TransactionAmount/TransactionCurrency are the canonical fields sourced from +// the latest active matching expense. type ExpenseSuggestion struct { - Name string `json:"name"` - Amount int64 `json:"amount"` - Currency string `json:"currency"` - ExpenseType string `json:"expenseType"` - TagID string `json:"tagId"` - Frequency int32 `json:"frequency"` - LastUsedAt string `json:"lastUsedAt"` - RecencyBucket string `json:"recencyBucket"` - FrecencyScore float64 `json:"frecencyScore"` + Name string `json:"name"` + TransactionAmount int64 `json:"transactionAmount"` + TransactionCurrency string `json:"transactionCurrency"` + ExpenseType string `json:"expenseType"` + TagID string `json:"tagId"` + Frequency int32 `json:"frequency"` + LastUsedAt string `json:"lastUsedAt"` + RecencyBucket string `json:"recencyBucket"` + FrecencyScore float64 `json:"frecencyScore"` } // ExpenseSuggestionListResponse follows the app pagination shape. diff --git a/services/expense/internal/repository/immudb.go b/services/expense/internal/repository/immudb.go index 13f0e323..aadab412 100644 --- a/services/expense/internal/repository/immudb.go +++ b/services/expense/internal/repository/immudb.go @@ -2,12 +2,34 @@ package repository import ( "context" + "errors" "fmt" "log/slog" + "strings" "github.com/ItsThompson/gofin/services/expense/internal/model" ) +// SnapshotIntegrityError signals a row that is missing a required snapshot +// field. The repository logs this as expense_snapshot_integrity_error +// telemetry before returning the error to the caller. +// +// It is exported so errors.As can detect it across the %w wrap boundary. +type SnapshotIntegrityError struct { + ExpenseID string + MissingFields []string +} + +func (e *SnapshotIntegrityError) Error() string { + return fmt.Sprintf("expense row %s: missing required snapshot fields: %s", e.ExpenseID, strings.Join(e.MissingFields, ", ")) +} + +// ReportData satisfies the errkit DataCarrier interface so a report of this +// error carries the expense id and the missing field names automatically. +func (e *SnapshotIntegrityError) ReportData() map[string]any { + return map[string]any{"expense_id": e.ExpenseID, "missing_fields": e.MissingFields} +} + // ImmudbExpenseRepository implements ExpenseRepository using immudb's SQL interface. type ImmudbExpenseRepository struct { client ImmudbClient @@ -29,8 +51,6 @@ func (r *ImmudbExpenseRepository) InitSchema(ctx context.Context) error { id VARCHAR(36) NOT NULL, user_id VARCHAR(36) NOT NULL, name VARCHAR(255) NOT NULL, - amount INTEGER NOT NULL, - currency VARCHAR(3) NOT NULL, expense_type VARCHAR(20) NOT NULL, tag_id VARCHAR(36) NOT NULL, expense_date VARCHAR(10) NOT NULL, @@ -84,9 +104,25 @@ func (r *ImmudbExpenseRepository) InitSchema(ctx context.Context) error { } } - // Run the one-time legacy money-snapshot migration after the columns exist. - if err := r.backfillMoneySnapshots(ctx); err != nil { - return err + // Drop the legacy amount/currency columns from tables created before the + // multi-currency cutover. This is temporary: remove it once prod has booted + // with the new schema and the pre-cutover tables have been migrated. + dropColumns := []string{ + `ALTER TABLE expenses DROP COLUMN amount;`, + `ALTER TABLE expenses DROP COLUMN currency;`, + } + for _, stmt := range dropColumns { + if _, dropErr := r.client.SQLExec(ctx, stmt, nil); dropErr != nil { + if strings.Contains(strings.ToLower(dropErr.Error()), "does not exist") { + // Expected when the column was already dropped (idempotent drop). + r.logger.Debug("legacy column drop skipped (already dropped)", + slog.String("statement", stmt), + slog.String("error", dropErr.Error()), + ) + continue + } + return fmt.Errorf("dropping legacy column: %w", dropErr) + } } indexes := []string{ @@ -113,98 +149,17 @@ func (r *ImmudbExpenseRepository) InitSchema(ctx context.Context) error { return nil } -// backfillBatchSize is the maximum number of rows updated per SQLExec during -// the money-snapshot backfill. immudb's DefaultMaxTxEntries is 1024 key-value -// entries per transaction; during backfill (before secondary indexes are -// created) each row UPDATE generates approximately one entry, so 100 provides -// a 10x safety margin. Batching prevents the "max number of entries per tx -// exceeded" error that a single bulk UPDATE triggers on large tables. -const backfillBatchSize = 100 - -// backfillMoneySnapshots backfills rows missing a transaction amount with an -// identity snapshot in their legacy currency. It is idempotent and resumable: -// each batch UPDATEs at most backfillBatchSize rows matching the WHERE -// predicate, so a crash mid-loop resumes cleanly on the next startup: rows -// already backfilled have a non-NULL transaction_amount and are excluded. -// -// TODO: delete this method once the mc/03 migration has shipped and prod has -// booted with the new code. It is a one-time data migration, not startup logic. -func (r *ImmudbExpenseRepository) backfillMoneySnapshots(ctx context.Context) error { - backfillCountQuery := `SELECT COUNT(*) FROM expenses - WHERE transaction_amount IS NULL - AND status <> 'redacted';` - countResult, err := r.client.SQLQuery(ctx, backfillCountQuery, nil) - if err != nil { - return fmt.Errorf("counting legacy money snapshot rows: %w", err) - } - var legacyRows int64 - if len(countResult.Rows) > 0 && len(countResult.Rows[0].Values) > 0 { - legacyRows = countResult.Rows[0].Values[0].GetInt() - } - r.logger.Info("money_snapshot_backfill check", - slog.String("event", "money_snapshot_backfill"), - slog.Int64("legacy_rows", legacyRows), - ) - if legacyRows == 0 { - return nil - } - - backfillUpdate := fmt.Sprintf(`UPDATE expenses SET - transaction_amount = amount, - transaction_currency = currency, - reporting_amount = amount, - reporting_currency = currency, - exchange_rate = '1', - exchange_rate_source = '%s', - exchange_rate_timestamp = created_at - WHERE transaction_amount IS NULL - AND status <> 'redacted' - LIMIT %d;`, model.ExchangeSourceMigration, backfillBatchSize) - - var totalBackfilled int64 - for { - if _, err := r.client.SQLExec(ctx, backfillUpdate, nil); err != nil { - return fmt.Errorf("backfilling legacy money snapshots: %w", err) - } - - remainingResult, err := r.client.SQLQuery(ctx, backfillCountQuery, nil) - if err != nil { - return fmt.Errorf("counting remaining legacy rows after backfill batch: %w", err) - } - var remaining int64 - if len(remainingResult.Rows) > 0 && len(remainingResult.Rows[0].Values) > 0 { - remaining = remainingResult.Rows[0].Values[0].GetInt() - } - totalBackfilled += legacyRows - remaining - r.logger.Info("money_snapshot_backfill batch complete", - slog.String("event", "money_snapshot_backfill_batch"), - slog.Int64("remaining", remaining), - slog.Int64("backfilled_so_far", totalBackfilled), - ) - legacyRows = remaining - if remaining == 0 { - break - } - } - - r.logger.Info("money_snapshot_backfill done", - slog.String("event", "money_snapshot_backfill_done"), - slog.Int64("total_backfilled", totalBackfilled), - ) - return nil -} - // CreateExpense inserts a new expense entry into the immudb ledger. func (r *ImmudbExpenseRepository) CreateExpense(ctx context.Context, expense *model.Expense) (*model.Expense, error) { query := `INSERT INTO expenses ( - id, user_id, name, amount, currency, expense_type, tag_id, + id, user_id, name, expense_type, tag_id, expense_date, period_year, period_month, status, corrects_id, is_pro_rata, pro_rata_group, pro_rata_index, pro_rata_total, created_at, transaction_amount, transaction_currency, reporting_amount, reporting_currency, exchange_rate, exchange_rate_source, exchange_rate_timestamp, exchange_rate_expires_at ) VALUES ( - @id, @user_id, @name, @amount, @currency, @expense_type, @tag_id, + @id, @user_id, @name, @expense_type, @tag_id, @expense_date, @period_year, @period_month, @status, @corrects_id, @is_pro_rata, @pro_rata_group, @pro_rata_index, @pro_rata_total, @created_at, @transaction_amount, @transaction_currency, @@ -216,8 +171,6 @@ func (r *ImmudbExpenseRepository) CreateExpense(ctx context.Context, expense *mo "id": expense.ID, "user_id": expense.UserID, "name": expense.Name, - "amount": expense.Amount, - "currency": expense.Currency, "expense_type": expense.ExpenseType, "tag_id": expense.TagID, "expense_date": expense.ExpenseDate, @@ -300,7 +253,7 @@ func (r *ImmudbExpenseRepository) GetExpensesForPeriod(ctx context.Context, user expenses := make([]*model.Expense, 0, len(result.Rows)) for _, row := range result.Rows { - expense, convErr := rowToExpense(row) + expense, convErr := r.mapRow(row) if convErr != nil { return nil, 0, fmt.Errorf("mapping expense row: %w", convErr) } @@ -326,7 +279,7 @@ func (r *ImmudbExpenseRepository) GetExpenseByID(ctx context.Context, id string, return nil, nil } - expense, err := rowToExpense(result.Rows[0]) + expense, err := r.mapRow(result.Rows[0]) if err != nil { return nil, fmt.Errorf("mapping expense row: %w", err) } @@ -360,8 +313,8 @@ const expenseSuggestionInputLimit int32 = 1000 // GetActiveExpenseSuggestionInputs returns recent active expense rows for suggestion ranking. func (r *ImmudbExpenseRepository) GetActiveExpenseSuggestionInputs(ctx context.Context, userID string) ([]*model.ExpenseSuggestionInput, error) { - query := `SELECT id, name, amount, currency, expense_type, tag_id, created_at, - expense_date, is_pro_rata, pro_rata_group + query := `SELECT id, name, transaction_amount, transaction_currency, + expense_type, tag_id, created_at, expense_date, is_pro_rata, pro_rata_group FROM expenses WHERE user_id = @user_id AND status = 'active' @@ -384,16 +337,36 @@ func (r *ImmudbExpenseRepository) GetActiveExpenseSuggestionInputs(ctx context.C return inputs, nil } +// mapRow wraps rowToExpense with telemetry. When rowToExpense returns a +// SnapshotIntegrityError (row missing required fields), it logs the +// expense_snapshot_integrity_error event before returning the error, so the +// data-integrity issue is recorded even though the read fails. +func (r *ImmudbExpenseRepository) mapRow(row SQLRow) (*model.Expense, error) { + expense, err := rowToExpense(row) + if err == nil { + return expense, nil + } + var integrityErr *SnapshotIntegrityError + if errors.As(err, &integrityErr) { + r.logger.Warn("expense snapshot integrity error", + slog.String("event", "expense_snapshot_integrity_error"), + slog.String("expense_id", integrityErr.ExpenseID), + slog.Any("missing_fields", integrityErr.MissingFields), + ) + } + return nil, err +} + // expenseColumnCount is the number of columns rowToExpense expects in a result // row. It must match the expenseSelectColumns list and the ExpenseData schema. -const expenseColumnCount = 25 +const expenseColumnCount = 23 // rowToExpense maps a result row to an Expense. The column order must match the // SELECT clause in queries. It returns an error on a short/malformed row rather // than panicking on an out-of-range index. // -// Every row must carry the required snapshot fields. InitSchema backfills rows -// missing them at startup, so a row still missing a required field on read is a +// Every row must carry the required snapshot fields. The mc/03 migration +// backfilled legacy rows, so a row still missing a required field on read is a // data-integrity error. func rowToExpense(row SQLRow) (*model.Expense, error) { values := row.Values @@ -404,35 +377,52 @@ func rowToExpense(row SQLRow) (*model.Expense, error) { ID: values[0].GetString(), UserID: values[1].GetString(), Name: values[2].GetString(), - Amount: values[3].GetInt(), - Currency: values[4].GetString(), - ExpenseType: values[5].GetString(), - TagID: values[6].GetString(), - ExpenseDate: values[7].GetString(), - PeriodYear: int32(values[8].GetInt()), - PeriodMonth: int32(values[9].GetInt()), - Status: values[10].GetString(), - CorrectsID: values[11].GetString(), - IsProRata: values[12].GetBool(), - ProRataGroup: values[13].GetString(), - ProRataIndex: int32(values[14].GetInt()), - ProRataTotal: int32(values[15].GetInt()), - CreatedAt: values[16].GetString(), - TransactionAmount: values[17].GetInt(), - TransactionCurrency: values[18].GetString(), - ReportingAmount: values[19].GetInt(), - ReportingCurrency: values[20].GetString(), - ExchangeRate: values[21].GetString(), - ExchangeRateSource: values[22].GetString(), - ExchangeRateTimestamp: values[23].GetString(), - ExchangeRateExpiresAt: values[24].GetString(), - } - - if exp.TransactionAmount == 0 || exp.TransactionCurrency == "" || - exp.ReportingAmount == 0 || exp.ReportingCurrency == "" || - exp.ExchangeRate == "" || exp.ExchangeRateSource == "" || - exp.ExchangeRateTimestamp == "" { - return nil, fmt.Errorf("expense row %s: missing required snapshot fields", exp.ID) + ExpenseType: values[3].GetString(), + TagID: values[4].GetString(), + ExpenseDate: values[5].GetString(), + PeriodYear: int32(values[6].GetInt()), + PeriodMonth: int32(values[7].GetInt()), + Status: values[8].GetString(), + CorrectsID: values[9].GetString(), + IsProRata: values[10].GetBool(), + ProRataGroup: values[11].GetString(), + ProRataIndex: int32(values[12].GetInt()), + ProRataTotal: int32(values[13].GetInt()), + CreatedAt: values[14].GetString(), + TransactionAmount: values[15].GetInt(), + TransactionCurrency: values[16].GetString(), + ReportingAmount: values[17].GetInt(), + ReportingCurrency: values[18].GetString(), + ExchangeRate: values[19].GetString(), + ExchangeRateSource: values[20].GetString(), + ExchangeRateTimestamp: values[21].GetString(), + ExchangeRateExpiresAt: values[22].GetString(), + } + + missing := make([]string, 0, 7) + if exp.TransactionAmount == 0 { + missing = append(missing, "transaction_amount") + } + if exp.TransactionCurrency == "" { + missing = append(missing, "transaction_currency") + } + if exp.ReportingAmount == 0 { + missing = append(missing, "reporting_amount") + } + if exp.ReportingCurrency == "" { + missing = append(missing, "reporting_currency") + } + if exp.ExchangeRate == "" { + missing = append(missing, "exchange_rate") + } + if exp.ExchangeRateSource == "" { + missing = append(missing, "exchange_rate_source") + } + if exp.ExchangeRateTimestamp == "" { + missing = append(missing, "exchange_rate_timestamp") + } + if len(missing) > 0 { + return nil, &SnapshotIntegrityError{ExpenseID: exp.ID, MissingFields: missing} } return exp, nil @@ -442,16 +432,16 @@ func rowToExpense(row SQLRow) (*model.Expense, error) { func rowToExpenseSuggestionInput(row SQLRow) *model.ExpenseSuggestionInput { values := row.Values return &model.ExpenseSuggestionInput{ - ID: values[0].GetString(), - Name: values[1].GetString(), - Amount: values[2].GetInt(), - Currency: values[3].GetString(), - ExpenseType: values[4].GetString(), - TagID: values[5].GetString(), - CreatedAt: values[6].GetString(), - ExpenseDate: values[7].GetString(), - IsProRata: values[8].GetBool(), - ProRataGroup: values[9].GetString(), + ID: values[0].GetString(), + Name: values[1].GetString(), + TransactionAmount: values[2].GetInt(), + TransactionCurrency: values[3].GetString(), + ExpenseType: values[4].GetString(), + TagID: values[5].GetString(), + CreatedAt: values[6].GetString(), + ExpenseDate: values[7].GetString(), + IsProRata: values[8].GetBool(), + ProRataGroup: values[9].GetString(), } } @@ -482,7 +472,7 @@ func (r *ImmudbExpenseRepository) CorrectExpense(ctx context.Context, original * } // expenseSelectColumns is the shared SELECT column list for expense queries. -const expenseSelectColumns = `id, user_id, name, amount, currency, expense_type, tag_id, +const expenseSelectColumns = `id, user_id, name, expense_type, tag_id, expense_date, period_year, period_month, status, corrects_id, is_pro_rata, pro_rata_group, pro_rata_index, pro_rata_total, created_at, transaction_amount, transaction_currency, @@ -542,7 +532,7 @@ func (r *ImmudbExpenseRepository) GetCorrectionHistory(ctx context.Context, expe break } - next, convErr := rowToExpense(result.Rows[0]) + next, convErr := r.mapRow(result.Rows[0]) if convErr != nil { return nil, fmt.Errorf("mapping expense row: %w", convErr) } @@ -574,7 +564,7 @@ func (r *ImmudbExpenseRepository) GetProRataGroup(ctx context.Context, groupID s expenses := make([]*model.Expense, 0, len(result.Rows)) for _, row := range result.Rows { - expense, convErr := rowToExpense(row) + expense, convErr := r.mapRow(row) if convErr != nil { return nil, fmt.Errorf("mapping expense row: %w", convErr) } @@ -626,7 +616,7 @@ func (r *ImmudbExpenseRepository) GetExpensesByUserAfter(ctx context.Context, us rows := make([]*model.Expense, 0, len(result.Rows)) for _, row := range result.Rows { - expense, convErr := rowToExpense(row) + expense, convErr := r.mapRow(row) if convErr != nil { return nil, ExpenseCursor{}, false, fmt.Errorf("mapping expense row: %w", convErr) } @@ -657,8 +647,6 @@ func (r *ImmudbExpenseRepository) AnonymizeAllUserExpenses(ctx context.Context, query := `UPDATE expenses SET user_id = 'DELETED', name = 'REDACTED', - amount = 0, - currency = '', expense_type = '', tag_id = '', expense_date = '', diff --git a/services/expense/internal/repository/immudb_test.go b/services/expense/internal/repository/immudb_test.go index 67d9b6fe..9f66ce04 100644 --- a/services/expense/internal/repository/immudb_test.go +++ b/services/expense/internal/repository/immudb_test.go @@ -1,11 +1,11 @@ package repository import ( + "bytes" "context" "errors" "io" "log/slog" - "strconv" "strings" "testing" @@ -70,8 +70,8 @@ func TestGetActiveExpenseSuggestionInputs_ReadsActiveRowsForUserAndMapsFields(t require.Len(t, inputs, 1) assert.Equal(t, "exp-1", inputs[0].ID) assert.Equal(t, "Groceries", inputs[0].Name) - assert.Equal(t, int64(2500), inputs[0].Amount) - assert.Equal(t, "USD", inputs[0].Currency) + assert.Equal(t, int64(2500), inputs[0].TransactionAmount) + assert.Equal(t, "USD", inputs[0].TransactionCurrency) assert.Equal(t, "essentials", inputs[0].ExpenseType) assert.Equal(t, "tag-food", inputs[0].TagID) assert.Equal(t, "2026-05-31T10:00:00Z", inputs[0].CreatedAt) @@ -81,7 +81,7 @@ func TestGetActiveExpenseSuggestionInputs_ReadsActiveRowsForUserAndMapsFields(t } func TestGetExpenseByID_ShortRowReturnsErrorNotPanic(t *testing.T) { - // A malformed/short row (fewer than the 25 selected columns) must produce a + // A malformed/short row (fewer than the 23 selected columns) must produce a // wrapped error rather than an index-out-of-range panic. client := &fakeImmudbClient{result: &SQLResult{Rows: []SQLRow{{Values: []SQLValue{ fakeSQLValue{stringValue: "exp-1"}, @@ -95,18 +95,16 @@ func TestGetExpenseByID_ShortRowReturnsErrorNotPanic(t *testing.T) { require.Error(t, err) assert.Nil(t, expense) - assert.Contains(t, err.Error(), "expense row has 3 values, want 25") + assert.Contains(t, err.Error(), "expense row has 3 values, want 23") } -// legacyRow builds a 25-value row in expenseSelectColumns order with empty +// legacyRow builds a 23-value row in expenseSelectColumns order with empty // snapshot fields, simulating a pre-cutover legacy row. func legacyRow() SQLRow { return SQLRow{Values: []SQLValue{ fakeSQLValue{stringValue: "exp-1"}, fakeSQLValue{stringValue: "user-1"}, fakeSQLValue{stringValue: "Groceries"}, - fakeSQLValue{intValue: 2500}, - fakeSQLValue{stringValue: "USD"}, fakeSQLValue{stringValue: "essentials"}, fakeSQLValue{stringValue: "tag-food"}, fakeSQLValue{stringValue: "2026-05-03"}, @@ -131,8 +129,8 @@ func legacyRow() SQLRow { } // TestRowToExpense_MissingRequiredSnapshotFieldsErrors asserts a row with empty -// snapshot fields is rejected as a data-integrity error. InitSchema backfills -// every legacy row at startup, so a row still missing a required field on read +// snapshot fields is rejected as a data-integrity error. The mc/03 migration +// backfilled every legacy row, so a row still missing a required field on read // must not be synthesized. func TestRowToExpense_MissingRequiredSnapshotFieldsErrors(t *testing.T) { expense, err := rowToExpense(legacyRow()) @@ -147,13 +145,13 @@ func TestRowToExpense_MissingRequiredSnapshotFieldsErrors(t *testing.T) { func TestRowToExpense_IdentitySnapshotRowUsesExplicitFields(t *testing.T) { row := legacyRow() // Overwrite the snapshot columns with explicit identity values. - row.Values[17] = fakeSQLValue{intValue: 1250} // transaction_amount - row.Values[18] = fakeSQLValue{stringValue: "EUR"} // transaction_currency - row.Values[19] = fakeSQLValue{intValue: 1364} // reporting_amount - row.Values[20] = fakeSQLValue{stringValue: "USD"} // reporting_currency - row.Values[21] = fakeSQLValue{stringValue: "1.0912"} // exchange_rate - row.Values[22] = fakeSQLValue{stringValue: model.ExchangeSourceIdentity} // exchange_rate_source - row.Values[23] = fakeSQLValue{stringValue: "2026-08-14T10:00:00Z"} // exchange_rate_timestamp + row.Values[15] = fakeSQLValue{intValue: 1250} // transaction_amount + row.Values[16] = fakeSQLValue{stringValue: "EUR"} // transaction_currency + row.Values[17] = fakeSQLValue{intValue: 1364} // reporting_amount + row.Values[18] = fakeSQLValue{stringValue: "USD"} // reporting_currency + row.Values[19] = fakeSQLValue{stringValue: "1.0912"} // exchange_rate + row.Values[20] = fakeSQLValue{stringValue: model.ExchangeSourceIdentity} // exchange_rate_source + row.Values[21] = fakeSQLValue{stringValue: "2026-08-14T10:00:00Z"} // exchange_rate_timestamp expense, err := rowToExpense(row) require.NoError(t, err) @@ -171,8 +169,8 @@ func TestRowToExpense_IdentitySnapshotRowUsesExplicitFields(t *testing.T) { // error rather than being silently synthesized or blended with legacy fields. func TestRowToExpense_MissingReportingFieldsReturnsIntegrityError(t *testing.T) { row := legacyRow() - row.Values[17] = fakeSQLValue{intValue: 1250} // transaction_amount - row.Values[18] = fakeSQLValue{stringValue: "EUR"} // transaction_currency + row.Values[15] = fakeSQLValue{intValue: 1250} // transaction_amount + row.Values[16] = fakeSQLValue{stringValue: "EUR"} // transaction_currency // reporting_amount, reporting_currency, exchange_rate, exchange_rate_source, // and exchange_rate_timestamp are left empty (missing). @@ -187,19 +185,68 @@ func TestRowToExpense_MissingReportingFieldsReturnsIntegrityError(t *testing.T) // timestamp is a required snapshot field (it is not optional). func TestRowToExpense_MissingExchangeRateTimestampReturnsIntegrityError(t *testing.T) { raw := legacyRow() + raw.Values[15] = fakeSQLValue{intValue: 2500} + raw.Values[16] = fakeSQLValue{stringValue: "USD"} raw.Values[17] = fakeSQLValue{intValue: 2500} raw.Values[18] = fakeSQLValue{stringValue: "USD"} - raw.Values[19] = fakeSQLValue{intValue: 2500} - raw.Values[20] = fakeSQLValue{stringValue: "USD"} - raw.Values[21] = fakeSQLValue{stringValue: "1"} - raw.Values[22] = fakeSQLValue{stringValue: model.ExchangeSourceIdentity} - raw.Values[23] = fakeSQLValue{stringValue: ""} // exchange_rate_timestamp missing + raw.Values[19] = fakeSQLValue{stringValue: "1"} + raw.Values[20] = fakeSQLValue{stringValue: model.ExchangeSourceIdentity} + raw.Values[21] = fakeSQLValue{stringValue: ""} // exchange_rate_timestamp missing _, err := rowToExpense(raw) require.Error(t, err) assert.Contains(t, err.Error(), "missing required snapshot fields") } +// TestRowToExpense_IntegrityErrorIsTyped asserts the integrity error is +// a *SnapshotIntegrityError so the repository's mapRow can detect it and emit +// telemetry. +func TestRowToExpense_IntegrityErrorIsTyped(t *testing.T) { + row := legacyRow() + row.Values[15] = fakeSQLValue{intValue: 1250} // transaction_amount + row.Values[16] = fakeSQLValue{stringValue: "EUR"} // transaction_currency + // remaining required fields empty + + expense, err := rowToExpense(row) + require.Error(t, err) + assert.Nil(t, expense) + var integrityErr *SnapshotIntegrityError + assert.True(t, errors.As(err, &integrityErr), "error must be *SnapshotIntegrityError") + assert.Equal(t, "exp-1", integrityErr.ExpenseID) + assert.ElementsMatch(t, []string{"reporting_amount", "reporting_currency", "exchange_rate", "exchange_rate_source", "exchange_rate_timestamp"}, integrityErr.MissingFields) + assert.Equal(t, map[string]any{"expense_id": "exp-1", "missing_fields": integrityErr.MissingFields}, integrityErr.ReportData()) +} + +// TestMapRow_LogsIntegrityErrorTelemetry asserts mapRow logs the +// expense_snapshot_integrity_error event when rowToExpense returns a +// SnapshotIntegrityError. +func TestMapRow_LogsIntegrityErrorTelemetry(t *testing.T) { + row := legacyRow() + row.Values[15] = fakeSQLValue{intValue: 1250} // transaction_amount + row.Values[16] = fakeSQLValue{stringValue: "EUR"} // transaction_currency + // remaining required fields empty + + client := &fakeImmudbClient{result: &SQLResult{Rows: []SQLRow{row}}} + var buf bytes.Buffer + repo := NewImmudbExpenseRepository(client, slog.New(slog.NewJSONHandler(&buf, nil))) + + expense, err := repo.GetExpenseByID(context.Background(), "exp-1", "user-1") + require.Error(t, err) + assert.Nil(t, expense) + + // The error should be wrapped but still detectable via errors.As. + var integrityErr *SnapshotIntegrityError + assert.True(t, errors.As(err, &integrityErr), "wrapped error should contain *SnapshotIntegrityError") + + // The integrity event must actually be emitted through mapRow. + assert.Contains(t, buf.String(), "expense_snapshot_integrity_error", + "mapRow must log the expense_snapshot_integrity_error event") + assert.Contains(t, buf.String(), "exp-1", + "integrity log must carry the expense id") + assert.Contains(t, buf.String(), "missing_fields", + "integrity log must carry the missing field names") +} + // TestInitSchema_ReconcilesSnapshotColumns asserts InitSchema issues the ALTER // TABLE ADD COLUMN statements for the money snapshot columns and does not fail // when the columns already exist (the recording client returns errors that the @@ -213,11 +260,15 @@ func TestInitSchema_ReconcilesSnapshotColumns(t *testing.T) { assert.Equal(t, 8, client.countQueriesContaining("ALTER TABLE EXPENSES ADD COLUMN"), "expected one ALTER per snapshot column") + assert.Equal(t, 2, client.countQueriesContaining("ALTER TABLE EXPENSES DROP COLUMN"), + "expected one DROP per legacy column") } // alterFailingImmudbClient wraps the recording client and fails every ALTER TABLE -// SQLExec with a "column already exists" error, modelling the idempotent -// reconcile path on a table that already has the snapshot columns. +// SQLExec with the idempotent error for that statement kind: ADD COLUMN returns +// "column already exists" and DROP COLUMN returns "column does not exist", +// modelling a table that already has the snapshot columns and no longer has the +// legacy columns. type alterFailingImmudbClient struct { *recordingImmudbClient } @@ -225,14 +276,18 @@ type alterFailingImmudbClient struct { func (c *alterFailingImmudbClient) SQLExec(ctx context.Context, sql string, params map[string]interface{}) (*SQLResult, error) { if strings.Contains(strings.ToUpper(sql), "ALTER TABLE") { c.record(sql, params) + if strings.Contains(strings.ToUpper(sql), "DROP COLUMN") { + return nil, errors.New("column does not exist") + } return nil, errors.New("column already exists") } return c.recordingImmudbClient.SQLExec(ctx, sql, params) } // TestInitSchema_SwallowsColumnExistsError asserts the reconcile ALTER path is -// idempotent: when the snapshot columns already exist (ALTER returns an -// error), InitSchema still succeeds and issues all 9 ALTER statements. +// idempotent: when the snapshot columns already exist and the legacy columns +// are already dropped, InitSchema still succeeds and issues all ADD and DROP +// statements. func TestInitSchema_SwallowsColumnExistsError(t *testing.T) { client := &alterFailingImmudbClient{recordingImmudbClient: newRecordingImmudbClient()} logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) @@ -241,89 +296,35 @@ func TestInitSchema_SwallowsColumnExistsError(t *testing.T) { require.NoError(t, repo.InitSchema(context.Background())) assert.Equal(t, 8, client.countQueriesContaining("ALTER TABLE EXPENSES ADD COLUMN"), - "expected all 8 ALTER statements to be issued even when they fail") + "expected all 8 ADD COLUMN statements to be issued even when they fail") + assert.Equal(t, 2, client.countQueriesContaining("ALTER TABLE EXPENSES DROP COLUMN"), + "expected both DROP COLUMN statements to be issued even when they fail") } -// TestInitSchema_BackfillsLegacyRowsAndSkipsRedacted seeds one legacy active -// row (transaction_amount empty) and one redacted row, then asserts InitSchema -// counts exactly the active row and issues a backfill UPDATE whose WHERE clause -// excludes redacted rows (the safety net that keeps anonymized rows untouched). -func TestInitSchema_BackfillsLegacyRowsAndSkipsRedacted(t *testing.T) { - legacy := buildTestExpense("exp-legacy", "user-1", "2026-05-03T10:00:00Z") - legacy.TransactionAmount = 0 - redacted := buildTestExpense("exp-redacted", "user-1", "2026-05-04T10:00:00Z") - redacted.Status = "redacted" - redacted.TransactionAmount = 0 - - client := newRecordingImmudbClient(legacy, redacted) - logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - repo := NewImmudbExpenseRepository(client, logger) - - require.NoError(t, repo.InitSchema(context.Background())) - - var updateSQL string - for _, query := range client.Queries() { - if strings.Contains(strings.ToUpper(query.SQL), "TRANSACTION_AMOUNT = AMOUNT") { - updateSQL = query.SQL - } - } - require.NotEmpty(t, updateSQL, "expected a backfill UPDATE for the seeded legacy row") - assert.Contains(t, updateSQL, "status <> 'redacted'") - assert.Contains(t, updateSQL, "exchange_rate_source = 'migration'") - assert.Contains(t, updateSQL, "transaction_amount = amount") - assert.Contains(t, updateSQL, "transaction_currency = currency") - assert.Contains(t, updateSQL, "reporting_amount = amount") - assert.Contains(t, updateSQL, "reporting_currency = currency") - assert.Contains(t, updateSQL, "exchange_rate_timestamp = created_at") - assert.Contains(t, strings.ToUpper(updateSQL), "LIMIT", - "backfill UPDATE must include a LIMIT clause to stay under the immudb tx-entry cap") +// dropFailingImmudbClient wraps the recording client and fails DROP COLUMN +// statements with a non-idempotent error, modelling a real DROP failure that +// must not be swallowed. +type dropFailingImmudbClient struct { + *recordingImmudbClient } -// TestInitSchema_BackfillBatchesWhenLegacyRowsExceedChunkSize seeds more legacy -// rows than backfillBatchSize and asserts the backfill issues multiple bounded -// UPDATEs (one per batch) and terminates once all rows are backfilled. -func TestInitSchema_BackfillBatchesWhenLegacyRowsExceedChunkSize(t *testing.T) { - const legacyCount = backfillBatchSize + 50 // 150 rows: needs 2 batches of 100 - rows := make([]*model.Expense, legacyCount) - for i := range rows { - e := buildTestExpense( - "exp-"+strconv.FormatInt(int64(i), 10), - "user-1", - "2026-05-03T10:00:00Z", - ) - e.TransactionAmount = 0 - rows[i] = e - } - - client := newRecordingImmudbClient(rows...) - logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - repo := NewImmudbExpenseRepository(client, logger) - - require.NoError(t, repo.InitSchema(context.Background())) - - updateCount := client.countQueriesContaining("TRANSACTION_AMOUNT = AMOUNT") - assert.Equal(t, 2, updateCount, - "expected 2 batched UPDATEs for %d legacy rows with batch size %d", legacyCount, backfillBatchSize) - - // Every backfilled row should now have a non-zero transaction amount. - for i, row := range rows { - assert.NotEqual(t, int64(0), row.TransactionAmount, - "row %d was not backfilled", i) +func (c *dropFailingImmudbClient) SQLExec(ctx context.Context, sql string, params map[string]interface{}) (*SQLResult, error) { + if strings.Contains(strings.ToUpper(sql), "DROP COLUMN") { + c.record(sql, params) + return nil, errors.New("permission denied") } + return c.recordingImmudbClient.SQLExec(ctx, sql, params) } -// TestInitSchema_SkipsBackfillWhenNoLegacyRows asserts an empty store issues the -// backfill COUNT but no UPDATE, which is what keeps the local in-memory stub -// (empty at startup and UPDATE-averse) from ever reaching the UPDATE. -func TestInitSchema_SkipsBackfillWhenNoLegacyRows(t *testing.T) { - client := newRecordingImmudbClient() +// TestInitSchema_DropColumnFailureFailsFast asserts a DROP COLUMN error other +// than "column does not exist" is returned, not swallowed, so a real DROP +// failure cannot leave the legacy NOT NULL columns in place. +func TestInitSchema_DropColumnFailureFailsFast(t *testing.T) { + client := &dropFailingImmudbClient{recordingImmudbClient: newRecordingImmudbClient()} logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) repo := NewImmudbExpenseRepository(client, logger) - require.NoError(t, repo.InitSchema(context.Background())) - - assert.Equal(t, 1, client.countQueriesContaining("TRANSACTION_AMOUNT IS NULL"), - "expected the backfill COUNT to be issued") - assert.Equal(t, 0, client.countQueriesContaining("TRANSACTION_AMOUNT = AMOUNT"), - "no legacy rows means no backfill UPDATE") + err := repo.InitSchema(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "dropping legacy column") } diff --git a/services/expense/internal/repository/keyset_integration_test.go b/services/expense/internal/repository/keyset_integration_test.go index a3b8186b..4c889943 100644 --- a/services/expense/internal/repository/keyset_integration_test.go +++ b/services/expense/internal/repository/keyset_integration_test.go @@ -199,75 +199,3 @@ func TestGetExpensesByUserAfter_Integration_KeysetOrderingAndNoOffset(t *testing } assert.True(t, sawKeysetPredicate, "expected the expanded-OR keyset predicate to be issued") } - -// TestInitSchema_BackfillBatchesLargeLegacySet_Integration seeds more legacy -// rows than immudb's DefaultMaxTxEntries (1024) and asserts the batched -// backfill completes without the "max number of entries per tx exceeded" error. -// This validates that UPDATE ... LIMIT n is accepted by the real immudb 1.11.0 -// SQL engine and that the loop drains all legacy rows. -// -// Run with a live immudb: -// -// docker run -d --rm -p 3322:3322 codenotary/immudb:1.11.0 -// go test -tags integration -run Integration_Backfill ./internal/repository/... -func TestInitSchema_BackfillBatchesLargeLegacySet_Integration(t *testing.T) { - client := connectRealImmudb(t) - logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - repo := NewImmudbExpenseRepository(client, logger) - ctx := context.Background() - - require.NoError(t, repo.InitSchema(ctx)) - - runID := strconv.FormatInt(time.Now().UnixNano(), 10) - userID := "itest-backfill-" + runID - - // Seed 1200 legacy rows (more than DefaultMaxTxEntries=1024) by inserting - // them one at a time via raw SQL that omits the snapshot columns, leaving - // them NULL so the backfill picks them up. One row per tx stays under the - // entry limit. - const legacyCount = 1200 - for i := 0; i < legacyCount; i++ { - id := runID + "-" + strconv.Itoa(i) - _, err := client.SQLExec(ctx, - `INSERT INTO expenses (id, user_id, name, amount, currency, expense_type, tag_id, - expense_date, period_year, period_month, status, corrects_id, - is_pro_rata, pro_rata_group, pro_rata_index, pro_rata_total, created_at) - VALUES (@id, @user_id, @name, @amount, @currency, @expense_type, @tag_id, - @expense_date, @period_year, @period_month, @status, @corrects_id, - @is_pro_rata, @pro_rata_group, @pro_rata_index, @pro_rata_total, @created_at)`, - map[string]interface{}{ - "id": id, - "user_id": userID, - "name": "Legacy " + id, - "amount": int64(1000), - "currency": "USD", - "expense_type": "essentials", - "tag_id": "tag-1", - "expense_date": "2026-05-01", - "period_year": int64(2026), - "period_month": int64(5), - "status": "active", - "corrects_id": "", - "is_pro_rata": false, - "pro_rata_group": "", - "pro_rata_index": int64(0), - "pro_rata_total": int64(0), - "created_at": "2026-05-03T10:00:00Z", - }) - require.NoError(t, err, "failed to seed legacy row %d", i) - } - - // Re-run InitSchema to trigger the backfill. The batched UPDATE ... LIMIT - // must drain all 1200 rows without hitting the tx-entry cap. - require.NoError(t, repo.InitSchema(ctx), - "batched backfill should complete without max-entries-per-tx error") - - // Verify no legacy rows remain for this user. - res, err := client.SQLQuery(ctx, - `SELECT COUNT(*) FROM expenses WHERE transaction_amount IS NULL AND user_id = @user_id`, - map[string]interface{}{"user_id": userID}) - require.NoError(t, err) - require.NotEmpty(t, res.Rows) - assert.Equal(t, int64(0), res.Rows[0].Values[0].GetInt(), - "all legacy rows should be backfilled") -} diff --git a/services/expense/internal/repository/recording_client_test.go b/services/expense/internal/repository/recording_client_test.go index 0e083618..f716bf35 100644 --- a/services/expense/internal/repository/recording_client_test.go +++ b/services/expense/internal/repository/recording_client_test.go @@ -3,7 +3,6 @@ package repository import ( "context" "sort" - "strconv" "strings" "sync" @@ -67,46 +66,6 @@ func (c *recordingImmudbClient) countQueriesContaining(substr string) int { func (c *recordingImmudbClient) SQLExec(_ context.Context, sql string, params map[string]interface{}) (*SQLResult, error) { c.record(sql, params) - - // Simulate the backfill UPDATE so subsequent COUNT queries reflect the - // mutation. This lets batched-backfill tests observe multiple UPDATE - // iterations without a real database. - upper := strings.ToUpper(sql) - if strings.Contains(upper, "UPDATE EXPENSES SET") && - strings.Contains(upper, "TRANSACTION_AMOUNT = AMOUNT") { - limit := len(c.rows) // default: update all matching - if idx := strings.Index(upper, "LIMIT"); idx >= 0 { - rest := sql[idx+5:] - numEnd := 0 - for numEnd < len(rest) && rest[numEnd] == ' ' { - numEnd++ - } - start := numEnd - for numEnd < len(rest) && rest[numEnd] >= '0' && rest[numEnd] <= '9' { - numEnd++ - } - if n, err := strconv.Atoi(rest[start:numEnd]); err == nil && n >= 0 { - limit = n - } - } - updated := 0 - for _, row := range c.rows { - if updated >= limit { - break - } - if row.TransactionAmount == 0 && row.Status != "redacted" { - row.TransactionAmount = row.Amount - row.TransactionCurrency = row.Currency - row.ReportingAmount = row.Amount - row.ReportingCurrency = row.Currency - row.ExchangeRate = "1" - row.ExchangeRateSource = model.ExchangeSourceMigration - row.ExchangeRateTimestamp = row.CreatedAt - updated++ - } - } - } - return &SQLResult{}, nil } @@ -119,19 +78,6 @@ func (c *recordingImmudbClient) SQLQuery(ctx context.Context, sql string, params userID, _ := params["user_id"].(string) if strings.Contains(strings.ToUpper(sql), "COUNT(*)") { - // The money-snapshot backfill count is global (no user_id): count rows - // missing a transaction amount that are not redacted, mirroring the real - // predicate. - if strings.Contains(strings.ToUpper(sql), "TRANSACTION_AMOUNT IS NULL") { - var count int64 - for _, row := range c.rows { - if row.TransactionAmount == 0 && row.Status != "redacted" { - count++ - } - } - return &SQLResult{Rows: []SQLRow{{Values: []SQLValue{fakeSQLValue{intValue: count}}}}}, nil - } - var count int64 for _, row := range c.rows { if row.UserID == userID { @@ -202,8 +148,6 @@ func expensesToSQLResult(expenses []*model.Expense) *SQLResult { fakeSQLValue{stringValue: e.ID}, fakeSQLValue{stringValue: e.UserID}, fakeSQLValue{stringValue: e.Name}, - fakeSQLValue{intValue: e.Amount}, - fakeSQLValue{stringValue: e.Currency}, fakeSQLValue{stringValue: e.ExpenseType}, fakeSQLValue{stringValue: e.TagID}, fakeSQLValue{stringValue: e.ExpenseDate}, @@ -238,9 +182,7 @@ func buildTestExpense(id, userID, createdAt string) *model.Expense { ID: id, UserID: userID, Name: "Expense " + id, - Amount: 1000, TransactionCurrency: "USD", - Currency: "USD", ExpenseType: "essentials", TagID: "tag-1", ExpenseDate: "2026-05-01", diff --git a/services/expense/internal/service/expense.go b/services/expense/internal/service/expense.go index fed9c534..3a2f8991 100644 --- a/services/expense/internal/service/expense.go +++ b/services/expense/internal/service/expense.go @@ -26,22 +26,30 @@ var isoDateRegex = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) type ExpenseService struct { repo repository.ExpenseRepository periodClient PeriodContextClient + fxClient FxClient logger *slog.Logger clock func() time.Time } // NewExpenseService creates a new ExpenseService. The clock seam supplies the // current time for CreatedAt stamping and the period-lock check; production -// passes time.Now and tests inject a fixed clock. +// passes time.Now and tests inject a fixed clock. fxClient is required: a nil +// client is a programming error and panics immediately, so a forgotten wire-up +// fails at construction rather than on the first foreign-currency user. func NewExpenseService( repo repository.ExpenseRepository, periodClient PeriodContextClient, + fxClient FxClient, clock func() time.Time, logger *slog.Logger, ) *ExpenseService { + if fxClient == nil { + panic("NewExpenseService: fxClient must not be nil") + } return &ExpenseService{ repo: repo, periodClient: periodClient, + fxClient: fxClient, logger: logger, clock: clock, } @@ -58,6 +66,21 @@ func (s *ExpenseService) CreateExpense(ctx context.Context, userID string, req * return nil, err } + // Normalize the period reporting currency once and validate it before any + // currency resolution or FX work. Finance owns the stored value, but an + // unsupported reporting currency is an internal invariant violation that + // must surface as a 500, not a 400 transaction-currency error from the + // defaulting branch. Normalization keeps casing/whitespace drift from + // routing a same-currency write to FX or persisting mismatched casing. + reportingCurrency := normalizeCurrencyCode(period.ReportingCurrency) + if err := validateReportingCurrency(reportingCurrency); err != nil { + s.logger.Error("unsupported reporting currency from period context", + slog.String("event", "unsupported_reporting_currency"), + slog.String("reporting_currency", reportingCurrency), + ) + return nil, err + } + transactionCurrency, err := s.resolveCreateTransactionCurrency(period, req) if err != nil { return nil, err @@ -67,29 +90,28 @@ func (s *ExpenseService) CreateExpense(ctx context.Context, userID string, req * // Resolve the money snapshot. Same-currency expenses bypass the FX provider // and write an identity snapshot (rate "1", source "identity"). Foreign-currency - // conversion requires the FX provider, which is not wired until the - // foreign-currency ticket; until then a foreign-currency request cannot be - // converted and must not write a partial ledger row, so it returns - // CONVERSION_UNAVAILABLE (spec error matrix: FX unavailable → no write). + // conversion calls the FX Service ConvertAmount RPC before writing the row. var snapshot model.Expense - if transactionCurrency == period.ReportingCurrency { - snapshot = buildIdentitySnapshot(req.Amount, transactionCurrency, period.ReportingCurrency, now) + if transactionCurrency == reportingCurrency { + snapshot = buildIdentitySnapshot(req.Amount, transactionCurrency, reportingCurrency, now) } else { - s.logger.Info("foreign currency conversion unavailable", - slog.String("event", "foreign_currency_conversion_unavailable"), - slog.String("transaction_currency", transactionCurrency), - slog.String("reporting_currency", period.ReportingCurrency), - ) - return nil, conversionUnavailableError() + fxResp, convErr := s.fxClient.ConvertAmount(ctx, FxConvertRequest{ + Amount: req.Amount, + SourceCurrency: transactionCurrency, + TargetCurrency: reportingCurrency, + RequestedAt: now, + }) + if convErr != nil { + return nil, s.handleFxConversionFailure(convErr, transactionCurrency, reportingCurrency) + } + snapshot = buildProviderSnapshot(req.Amount, transactionCurrency, reportingCurrency, fxResp) } expense := &model.Expense{ ID: uuid.New().String(), UserID: userID, Name: req.Name, - Amount: req.Amount, TransactionCurrency: transactionCurrency, - Currency: transactionCurrency, ExpenseType: req.ExpenseType, TagID: req.TagID, ExpenseDate: req.ExpenseDate, @@ -120,7 +142,7 @@ func (s *ExpenseService) CreateExpense(ctx context.Context, userID string, req * slog.String("method", "CreateExpense"), slog.String("user_id", userID), slog.String("expense_id", created.ID), - slog.Int64("amount", created.Amount), + slog.Int64("amount", created.TransactionAmount), slog.String("expense_type", created.ExpenseType), ) @@ -240,9 +262,7 @@ func (s *ExpenseService) CorrectExpense(ctx context.Context, userID string, expe ID: uuid.New().String(), UserID: userID, Name: req.Name, - Amount: req.Amount, TransactionCurrency: transactionCurrency, - Currency: transactionCurrency, // immudb currency column is NOT NULL; mirror the transaction currency ExpenseType: req.ExpenseType, TagID: req.TagID, ExpenseDate: req.ExpenseDate, @@ -447,11 +467,57 @@ func unsupportedCurrencyError(currencyCode string) *apierr.Error { func conversionUnavailableError() *apierr.Error { return &apierr.Error{ Code: model.ErrConversionUnavailable, - Message: "currency conversion is unavailable, please try again later", + Message: "Conversion unavailable. Try again later, or enter the manually converted amount in the period currency.", Status: http.StatusServiceUnavailable, } } +// fxConversionError wraps an FX conversion failure that is the service's fault +// (a 500) and carries the currency pair so the handler's errkit.Report merges it +// into the Sentry context block and slog record automatically. +type fxConversionError struct { + err error + transactionCurrency string + reportingCurrency string +} + +func (e *fxConversionError) Error() string { return e.err.Error() } +func (e *fxConversionError) Unwrap() error { return e.err } +func (e *fxConversionError) ReportData() map[string]any { + return map[string]any{"transaction_currency": e.transactionCurrency, "reporting_currency": e.reportingCurrency} +} + +// handleFxConversionFailure classifies an FX conversion failure and returns the +// error the caller should surface. The handler reports 500s via errkit.Report, +// so the service only logs here and never reports: a 503 outage and a 400 client +// error are expected outcomes, while a 500 is wrapped in fxConversionError so the +// handler's report carries the currency pair. +func (s *ExpenseService) handleFxConversionFailure(err error, transactionCurrency, reportingCurrency string) error { + var apiErr *apierr.Error + if errors.As(err, &apiErr) && apiErr.Code == model.ErrConversionUnavailable { + s.logger.Info("foreign currency conversion unavailable", + slog.String("event", "foreign_currency_conversion_unavailable"), + slog.String("transaction_currency", transactionCurrency), + slog.String("reporting_currency", reportingCurrency), + ) + return err + } + if errors.As(err, &apiErr) && !apierr.IsServerError(apiErr) { + s.logger.Info("foreign currency conversion rejected", + slog.String("event", "foreign_currency_conversion_rejected"), + slog.String("transaction_currency", transactionCurrency), + slog.String("reporting_currency", reportingCurrency), + slog.String("error", err.Error()), + ) + return err + } + return &fxConversionError{ + err: err, + transactionCurrency: transactionCurrency, + reportingCurrency: reportingCurrency, + } +} + // buildIdentitySnapshot builds the money snapshot for a same-currency expense: // transaction and reporting amounts are equal, the rate is "1", and the // source is "identity". The exchange-rate timestamp is the ledger write time. @@ -468,6 +534,36 @@ func buildIdentitySnapshot(amount int64, transactionCurrency, reportingCurrency, } } +// buildProviderSnapshot builds the money snapshot for a foreign-currency expense +// from the FX Service ConvertAmount response. The transaction amount and +// currency are the user's original input (unchanged); the reporting amount is +// the FX-converted amount; and the exchange rate, source, timestamp, and expiry +// are the provider facts returned by FX. +func buildProviderSnapshot(transactionAmount int64, transactionCurrency, reportingCurrency string, fx *FxConvertResponse) model.Expense { + return model.Expense{ + TransactionAmount: transactionAmount, + TransactionCurrency: transactionCurrency, + ReportingAmount: fx.ConvertedAmount, + ReportingCurrency: reportingCurrency, + ExchangeRate: fx.ExchangeRate, + ExchangeRateSource: fx.Source, + ExchangeRateTimestamp: fx.RateTimestamp, + ExchangeRateExpiresAt: fx.ExpiresAt, + } +} + +// validateReportingCurrency checks that the period reporting currency is in the +// shared static catalog. The period context is trusted (Finance owns it), so an +// unsupported reporting currency is an internal invariant violation, not a user +// input error or a conversion outage. It surfaces as a 500 internal error. +func validateReportingCurrency(reportingCurrency string) *apierr.Error { + code := normalizeCurrencyCode(reportingCurrency) + if !currencycatalog.IsSupported(code) { + return apierr.Internal("reporting currency is not supported") + } + return nil +} + func (s *ExpenseService) resolveCreateTransactionCurrency(period *PeriodContext, req *model.CreateExpenseRequest) (string, error) { transactionCurrency := normalizeCurrencyCode(req.TransactionCurrency) if transactionCurrency == "" { diff --git a/services/expense/internal/service/expense_test.go b/services/expense/internal/service/expense_test.go index 1a291ff7..3aa067f8 100644 --- a/services/expense/internal/service/expense_test.go +++ b/services/expense/internal/service/expense_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "io" "log/slog" @@ -12,6 +13,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/ItsThompson/gofin/services/apierr" "github.com/ItsThompson/gofin/services/expense/internal/model" @@ -124,7 +127,7 @@ func newTestPeriodClient() *mockPeriodContextClient { func newTestService(repo *mockExpenseRepository) *ExpenseService { logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - return NewExpenseService(repo, newTestPeriodClient(), time.Now, logger) + return NewExpenseService(repo, newTestPeriodClient(), &stubFxClient{}, time.Now, logger) } // requireAPIError asserts that err carries an *apierr.Error (via errors.As, so a @@ -157,8 +160,7 @@ func TestCreateExpense_Success(t *testing.T) { repo.On("CreateExpense", mock.Anything, mock.MatchedBy(func(expense *model.Expense) bool { // Same-currency (USD/USD) create writes an identity snapshot. - return expense.Amount == 2500 && - expense.TransactionCurrency == "USD" && + return expense.TransactionCurrency == "USD" && expense.TransactionAmount == 2500 && expense.ReportingAmount == 2500 && expense.ReportingCurrency == "USD" && @@ -168,8 +170,6 @@ func TestCreateExpense_Success(t *testing.T) { ID: "exp-123", UserID: "user-1", Name: "Grocery shopping", - Amount: 2500, - Currency: "USD", ExpenseType: "essentials", TagID: "tag-food", ExpenseDate: "2026-05-03", @@ -188,21 +188,150 @@ func TestCreateExpense_Success(t *testing.T) { require.NoError(t, err) assert.Equal(t, "exp-123", expense.ID) assert.Equal(t, "user-1", expense.UserID) - assert.Equal(t, int64(2500), expense.Amount) + assert.Equal(t, int64(2500), expense.TransactionAmount) assert.Equal(t, "essentials", expense.ExpenseType) assert.Equal(t, "active", expense.Status) } -// TestCreateExpense_ForeignCurrencyReturnsConversionUnavailable asserts a -// transaction currency that differs from the period reporting currency cannot -// be written until the FX provider is wired: it returns CONVERSION_UNAVAILABLE -// (503) and does not call the repository, preserving the invariant that every -// new row carries a complete money snapshot. -func TestCreateExpense_ForeignCurrencyReturnsConversionUnavailable(t *testing.T) { +// TestNewExpenseService_NilFxClientPanics asserts that a forgotten FX wire-up +// fails loudly at construction rather than on the first foreign-currency user. +func TestNewExpenseService_NilFxClientPanics(t *testing.T) { + repo := new(mockExpenseRepository) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + + require.Panics(t, func() { + NewExpenseService(repo, newTestPeriodClient(), nil, time.Now, logger) + }) +} + +// mockFxClient implements FxClient for tests. +type mockFxClient struct { + mock.Mock +} + +func (m *mockFxClient) ConvertAmount(ctx context.Context, req FxConvertRequest) (*FxConvertResponse, error) { + args := m.Called(ctx, req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*FxConvertResponse), args.Error(1) +} + +// stubFxClient is a non-nil FxClient for tests that never exercise FX. A call +// to it means a same-currency test accidentally routed a foreign-currency +// request, so it fails loudly instead of returning zero values. +type stubFxClient struct{} + +func (stubFxClient) ConvertAmount(ctx context.Context, req FxConvertRequest) (*FxConvertResponse, error) { + return nil, fmt.Errorf("stubFxClient: unexpected ConvertAmount call") +} + +// TestCreateExpense_ForeignCurrencySuccessCallsFxAndWritesProviderSnapshot +// asserts that when transactionCurrency != reportingCurrency, the service calls +// FX ConvertAmount with the exact request, builds a provider snapshot from the +// FX response, and writes the ledger row with all snapshot metadata. +func TestCreateExpense_ForeignCurrencySuccessCallsFxAndWritesProviderSnapshot(t *testing.T) { repo := new(mockExpenseRepository) periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + svc := newTestServiceWithFxClock(repo, periodClient, fxClient, now) + + periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(5)).Return(&PeriodContext{ + PeriodID: "period-1", + UserID: "user-1", + Year: 2026, + Month: 5, + ReportingCurrency: "USD", + }, nil) + + requestedAt := now.UTC().Format(time.RFC3339) + fxResp := &FxConvertResponse{ + ConvertedAmount: 1364, + ExchangeRate: "1.0912", + RateTimestamp: "2026-08-14T10:00:00Z", + Source: model.ExchangeSourceOpenExchangeRates, + ExpiresAt: "2026-08-14T11:00:00Z", + } + + fxClient.On("ConvertAmount", mock.Anything, mock.MatchedBy(func(req FxConvertRequest) bool { + return req.Amount == 1250 && + req.SourceCurrency == "EUR" && + req.TargetCurrency == "USD" && + req.RequestedAt == requestedAt + })).Return(fxResp, nil) + + var captured *model.Expense + repo.On("CreateExpense", mock.Anything, mock.AnythingOfType("*model.Expense")). + Run(func(args mock.Arguments) { + captured = args.Get(1).(*model.Expense) + }).Return(&model.Expense{ + ID: "exp-fx-1", + UserID: "user-1", + Name: "Grocery shopping", + TransactionCurrency: "EUR", + ExpenseType: "essentials", + TagID: "tag-food", + ExpenseDate: "2026-05-03", + PeriodYear: 2026, + PeriodMonth: 5, + Status: "active", + TransactionAmount: 1250, + ReportingAmount: 1364, + ReportingCurrency: "USD", + ExchangeRate: "1.0912", + ExchangeRateSource: model.ExchangeSourceOpenExchangeRates, + ExchangeRateTimestamp: "2026-08-14T10:00:00Z", + ExchangeRateExpiresAt: "2026-08-14T11:00:00Z", + }, nil) + + req := validCreateRequest() + req.Amount = 1250 + req.TransactionCurrency = "EUR" + + resp, err := svc.CreateExpense(context.Background(), "user-1", req) + + require.NoError(t, err) + require.NotNil(t, captured) + + // The ledger row stores transaction amount/currency unchanged. + assert.Equal(t, int64(1250), captured.TransactionAmount) + assert.Equal(t, "EUR", captured.TransactionCurrency) + + // The ledger row stores the FX-converted reporting amount/currency. + assert.Equal(t, int64(1364), captured.ReportingAmount) + assert.Equal(t, "USD", captured.ReportingCurrency) + + // The ledger row stores the FX snapshot metadata. + assert.Equal(t, "1.0912", captured.ExchangeRate) + assert.Equal(t, model.ExchangeSourceOpenExchangeRates, captured.ExchangeRateSource) + assert.Equal(t, "2026-08-14T10:00:00Z", captured.ExchangeRateTimestamp) + assert.Equal(t, "2026-08-14T11:00:00Z", captured.ExchangeRateExpiresAt) + + // The response returns both transaction and reporting money fields plus + // snapshot metadata. + assert.Equal(t, int64(1250), resp.TransactionAmount) + assert.Equal(t, "EUR", resp.TransactionCurrency) + assert.Equal(t, int64(1364), resp.ReportingAmount) + assert.Equal(t, "USD", resp.ReportingCurrency) + assert.Equal(t, "1.0912", resp.ExchangeRate) + assert.Equal(t, model.ExchangeSourceOpenExchangeRates, resp.ExchangeRateSource) + assert.Equal(t, "2026-08-14T10:00:00Z", resp.ExchangeRateTimestamp) + assert.Equal(t, "2026-08-14T11:00:00Z", resp.ExchangeRateExpiresAt) + + fxClient.AssertExpectations(t) + repo.AssertExpectations(t) +} + +// TestCreateExpense_ForeignCurrencyFxUnavailableDoesNotWrite asserts that when +// FX returns a conversion-unavailable error, the service maps it to +// CONVERSION_UNAVAILABLE (503) and does not write a ledger row. +func TestCreateExpense_ForeignCurrencyFxUnavailableDoesNotWrite(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - svc := NewExpenseService(repo, periodClient, time.Now, logger) + svc := NewExpenseService(repo, periodClient, fxClient, time.Now, logger) periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(5)).Return(&PeriodContext{ PeriodID: "period-1", @@ -212,17 +341,214 @@ func TestCreateExpense_ForeignCurrencyReturnsConversionUnavailable(t *testing.T) ReportingCurrency: "USD", }, nil) + fxClient.On("ConvertAmount", mock.Anything, mock.MatchedBy(func(req FxConvertRequest) bool { + return req.SourceCurrency == "EUR" && req.TargetCurrency == "USD" + })).Return(nil, conversionUnavailableError()) + req := validCreateRequest() req.TransactionCurrency = "EUR" _, err := svc.CreateExpense(context.Background(), "user-1", req) + svcErr := requireAPIError(t, err) + assert.Equal(t, model.ErrConversionUnavailable, svcErr.Code) + assert.Equal(t, http.StatusServiceUnavailable, svcErr.Status) + repo.AssertNotCalled(t, "CreateExpense", mock.Anything, mock.Anything) + fxClient.AssertExpectations(t) +} + +// TestCreateExpense_ForeignCurrencyFxClientReturnsConversionUnavailableDoesNotWrite +// asserts that when the injected FxClient returns a conversion-unavailable +// error, the service maps it to the safe CONVERSION_UNAVAILABLE REST error and +// does not write a ledger row. +func TestCreateExpense_ForeignCurrencyFxClientReturnsConversionUnavailableDoesNotWrite(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + svc := NewExpenseService(repo, periodClient, fxClient, time.Now, logger) + + periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(5)).Return(&PeriodContext{ + PeriodID: "period-1", + UserID: "user-1", + Year: 2026, + Month: 5, + ReportingCurrency: "USD", + }, nil) + + fxClient.On("ConvertAmount", mock.Anything, mock.Anything).Return(nil, conversionUnavailableError()) + + req := validCreateRequest() + req.TransactionCurrency = "GBP" + + _, err := svc.CreateExpense(context.Background(), "user-1", req) + svcErr := requireAPIError(t, err) assert.Equal(t, model.ErrConversionUnavailable, svcErr.Code) assert.Equal(t, http.StatusServiceUnavailable, svcErr.Status) repo.AssertNotCalled(t, "CreateExpense", mock.Anything, mock.Anything) } +// TestCreateExpense_UnsupportedTransactionCurrencyDoesNotCallFx asserts that an +// unsupported transaction currency returns a 400 field-level validation error +// and does not call FX or write a ledger row. +func TestCreateExpense_UnsupportedTransactionCurrencyDoesNotCallFx(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + svc := NewExpenseService(repo, periodClient, fxClient, time.Now, logger) + + periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(5)).Return(&PeriodContext{ + PeriodID: "period-1", + UserID: "user-1", + Year: 2026, + Month: 5, + ReportingCurrency: "USD", + }, nil) + + req := validCreateRequest() + req.TransactionCurrency = "ZZZ" + + _, err := svc.CreateExpense(context.Background(), "user-1", req) + + svcErr := requireAPIError(t, err) + assert.Equal(t, model.ErrUnsupportedCurrency, svcErr.Code) + assert.Equal(t, http.StatusBadRequest, svcErr.Status) + repo.AssertNotCalled(t, "CreateExpense", mock.Anything, mock.Anything) + fxClient.AssertNotCalled(t, "ConvertAmount", mock.Anything, mock.Anything) +} + +// TestCreateExpense_UnsupportedReportingCurrencyDefaultsToInternal asserts that +// when both currency fields are omitted and the period reporting currency is +// unsupported, the reporting-currency invariant is checked before the +// transaction-currency defaulting branch, so the service returns a 500 internal +// error rather than a 400 UNSUPPORTED_CURRENCY. +func TestCreateExpense_UnsupportedReportingCurrencyDefaultsToInternal(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + svc := NewExpenseService(repo, periodClient, fxClient, time.Now, logger) + + periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(5)).Return(&PeriodContext{ + PeriodID: "period-1", + UserID: "user-1", + Year: 2026, + Month: 5, + ReportingCurrency: "XXX", + }, nil) + + req := validCreateRequest() + req.TransactionCurrency = "" + + _, err := svc.CreateExpense(context.Background(), "user-1", req) + + svcErr := requireAPIError(t, err) + assert.Equal(t, apierr.CodeInternal, svcErr.Code) + assert.Equal(t, http.StatusInternalServerError, svcErr.Status) + repo.AssertNotCalled(t, "CreateExpense", mock.Anything, mock.Anything) + fxClient.AssertNotCalled(t, "ConvertAmount", mock.Anything, mock.Anything) +} + +// TestCreateExpense_ForeignCurrencyFxServerFailureWrapsErrorWithCurrencyPair +// asserts an FX server failure (500) is wrapped in an error that carries the +// currency pair via ReportData, so the handler's errkit.Report merges it into +// the Sentry context and slog record. The service itself does not report. +func TestCreateExpense_ForeignCurrencyFxServerFailureWrapsErrorWithCurrencyPair(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + svc := NewExpenseService(repo, periodClient, fxClient, time.Now, logger) + + periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(5)).Return(&PeriodContext{ + PeriodID: "period-1", + UserID: "user-1", + Year: 2026, + Month: 5, + ReportingCurrency: "USD", + }, nil) + + fxClient.On("ConvertAmount", mock.Anything, mock.Anything).Return(nil, apierr.Internal("currency conversion failed internally")) + + req := validCreateRequest() + req.TransactionCurrency = "EUR" + + _, err := svc.CreateExpense(context.Background(), "user-1", req) + + require.Error(t, err) + var carrier interface{ ReportData() map[string]any } + require.ErrorAs(t, err, &carrier) + assert.Equal(t, map[string]any{"transaction_currency": "EUR", "reporting_currency": "USD"}, carrier.ReportData()) + repo.AssertNotCalled(t, "CreateExpense", mock.Anything, mock.Anything) +} + +// TestCreateExpense_ForeignCurrencyFxClientRejectionReturnsUnwrapped asserts +// an FX client rejection (400) is returned as the original *apierr.Error, not +// wrapped in fxConversionError, so the handler maps it to a 400 without +// reporting it as a service fault. +func TestCreateExpense_ForeignCurrencyFxClientRejectionReturnsUnwrapped(t *testing.T) { + repo := new(mockExpenseRepository) + periodClient := new(mockPeriodContextClient) + fxClient := new(mockFxClient) + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + svc := NewExpenseService(repo, periodClient, fxClient, time.Now, logger) + + periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(5)).Return(&PeriodContext{ + PeriodID: "period-1", + UserID: "user-1", + Year: 2026, + Month: 5, + ReportingCurrency: "USD", + }, nil) + + fxClient.On("ConvertAmount", mock.Anything, mock.Anything).Return(nil, apierr.Validation("The FX service rejected the conversion amount", nil)) + + req := validCreateRequest() + req.TransactionCurrency = "EUR" + + _, err := svc.CreateExpense(context.Background(), "user-1", req) + + require.Error(t, err) + var apiErr *apierr.Error + require.ErrorAs(t, err, &apiErr) + assert.Equal(t, apierr.CodeValidation, apiErr.Code) + + var carrier interface{ ReportData() map[string]any } + assert.False(t, errors.As(err, &carrier)) + repo.AssertNotCalled(t, "CreateExpense", mock.Anything, mock.Anything) +} + +// TestMapFxError asserts FX gRPC status codes preserve their category (spec 05): +// Unavailable/FailedPrecondition → 503, InvalidArgument → 400, Internal → 500, +// and a non-gRPC transport failure → 503. +func TestMapFxError(t *testing.T) { + tests := []struct { + name string + err error + expectedCode string + expectedStatus int + }{ + {"conversion unavailable", status.Error(codes.Unavailable, "CONVERSION_UNAVAILABLE"), model.ErrConversionUnavailable, http.StatusServiceUnavailable}, + {"provider auth failed", status.Error(codes.Unavailable, "PROVIDER_AUTH_FAILED"), model.ErrConversionUnavailable, http.StatusServiceUnavailable}, + {"provider response invalid", status.Error(codes.Unavailable, "PROVIDER_RESPONSE_INVALID"), model.ErrConversionUnavailable, http.StatusServiceUnavailable}, + {"rate missing live conversion", status.Error(codes.FailedPrecondition, "RATE_MISSING"), model.ErrConversionUnavailable, http.StatusServiceUnavailable}, + {"unsupported currency", status.Error(codes.InvalidArgument, "UNSUPPORTED_CURRENCY"), model.ErrUnsupportedCurrency, http.StatusBadRequest}, + {"invalid amount", status.Error(codes.InvalidArgument, "INVALID_AMOUNT"), apierr.CodeValidation, http.StatusBadRequest}, + {"internal", status.Error(codes.Internal, "INTERNAL"), apierr.CodeInternal, http.StatusInternalServerError}, + {"non-grpc error", fmt.Errorf("connection refused"), model.ErrConversionUnavailable, http.StatusServiceUnavailable}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apiErr := mapFxError(tt.err) + assert.Equal(t, tt.expectedCode, apiErr.Code) + assert.Equal(t, tt.expectedStatus, apiErr.Status) + }) + } +} + // TestCreateExpense_IdentitySnapshotBypassesFX asserts a same-currency write // populates the full identity snapshot and does not require any FX client setup. func TestCreateExpense_IdentitySnapshotBypassesFX(t *testing.T) { @@ -234,9 +560,7 @@ func TestCreateExpense_IdentitySnapshotBypassesFX(t *testing.T) { ID: "exp-xyz", UserID: "user-1", Name: "Grocery shopping", - Amount: 2500, TransactionCurrency: "USD", - Currency: "USD", ExpenseType: "essentials", TagID: "tag-food", ExpenseDate: "2026-05-03", @@ -249,11 +573,10 @@ func TestCreateExpense_IdentitySnapshotBypassesFX(t *testing.T) { ExchangeRate: "1", ExchangeRateSource: model.ExchangeSourceIdentity, } - repo.On("CreateExpense", mock.Anything, mock.MatchedBy(func(e *model.Expense) bool { - return true - })).Run(func(args mock.Arguments) { - captured = args.Get(1).(*model.Expense) - }).Return(returned, nil) + repo.On("CreateExpense", mock.Anything, mock.AnythingOfType("*model.Expense")). + Run(func(args mock.Arguments) { + captured = args.Get(1).(*model.Expense) + }).Return(returned, nil) resp, err := svc.CreateExpense(context.Background(), "user-1", validCreateRequest()) @@ -279,7 +602,7 @@ func TestCreateExpense_MissingPeriodDoesNotWrite(t *testing.T) { repo := new(mockExpenseRepository) periodClient := new(mockPeriodContextClient) logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - svc := NewExpenseService(repo, periodClient, time.Now, logger) + svc := NewExpenseService(repo, periodClient, &stubFxClient{}, time.Now, logger) periodClient.On("GetPeriodContext", mock.Anything, "user-1", int32(2026), int32(5)).Return(nil, periodNotFoundError(2026, 5)) @@ -327,7 +650,7 @@ func TestCreateExpense_CurrencyCompatibility(t *testing.T) { repo := new(mockExpenseRepository) periodClient := new(mockPeriodContextClient) logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - svc := NewExpenseService(repo, periodClient, time.Now, logger) + svc := NewExpenseService(repo, periodClient, &stubFxClient{}, time.Now, logger) reportingCurrency := tt.reportingCurrency if reportingCurrency == "" { @@ -345,13 +668,12 @@ func TestCreateExpense_CurrencyCompatibility(t *testing.T) { repo.On("CreateExpense", mock.Anything, mock.MatchedBy(func(expense *model.Expense) bool { // Identity snapshot for same-currency writes. return expense.TransactionCurrency == tt.expectedCurrency && - expense.Currency == tt.expectedCurrency && expense.TransactionAmount == int64(2500) && expense.ReportingAmount == int64(2500) && expense.ReportingCurrency == reportingCurrency && expense.ExchangeRate == "1" && expense.ExchangeRateSource == model.ExchangeSourceIdentity - })).Return(&model.Expense{ID: "exp-123", TransactionCurrency: tt.expectedCurrency, Currency: tt.expectedCurrency, Status: "active"}, nil) + })).Return(&model.Expense{ID: "exp-123", TransactionCurrency: tt.expectedCurrency, Status: "active"}, nil) } req := validCreateRequest() @@ -502,8 +824,8 @@ func TestGetExpensesForPeriod_Success(t *testing.T) { svc := newTestService(repo) expenses := []*model.Expense{ - {ID: "exp-1", Name: "Groceries", Amount: 5000, Status: "active"}, - {ID: "exp-2", Name: "Coffee", Amount: 500, Status: "active"}, + {ID: "exp-1", Name: "Groceries", Status: "active"}, + {ID: "exp-2", Name: "Coffee", Status: "active"}, } repo.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(5), int32(1), int32(50)). @@ -632,9 +954,7 @@ func activeExpenseInCurrentPeriod(now time.Time) *model.Expense { ID: "exp-original", UserID: "user-1", Name: "Coffee", - Amount: 500, TransactionCurrency: "USD", - Currency: "USD", ExpenseType: "desires", TagID: "tag-food", ExpenseDate: now.Format("2006-01-02"), @@ -662,7 +982,15 @@ func validCorrectRequest() *model.CorrectExpenseRequest { func newTestServiceWithClock(repo *mockExpenseRepository, now time.Time) *ExpenseService { logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) - return NewExpenseService(repo, newTestPeriodClient(), func() time.Time { return now }, logger) + return NewExpenseService(repo, newTestPeriodClient(), &stubFxClient{}, func() time.Time { return now }, logger) +} + +// newTestServiceWithFxClock builds a service with a custom period client, FX +// client, and fixed clock so FX tests can assert the exact request (including +// RequestedAt) and snapshot timestamps. +func newTestServiceWithFxClock(repo *mockExpenseRepository, periodClient *mockPeriodContextClient, fxClient FxClient, now time.Time) *ExpenseService { + logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) + return NewExpenseService(repo, periodClient, fxClient, func() time.Time { return now }, logger) } func TestCorrectExpense_Success(t *testing.T) { @@ -678,11 +1006,10 @@ func TestCorrectExpense_Success(t *testing.T) { correction := args.Get(2).(*model.Expense) // Verify correction fields assert.Equal(t, "Updated Coffee", correction.Name) - assert.Equal(t, int64(600), correction.Amount) + assert.Equal(t, int64(600), correction.TransactionAmount) assert.Equal(t, "active", correction.Status) assert.Equal(t, "exp-original", correction.CorrectsID) assert.Equal(t, "USD", correction.TransactionCurrency) - assert.Equal(t, "USD", correction.Currency) // Inherited, mirrored assert.Equal(t, original.PeriodYear, correction.PeriodYear) assert.Equal(t, original.PeriodMonth, correction.PeriodMonth) assert.NotEmpty(t, correction.ID) @@ -701,9 +1028,7 @@ func TestCorrectExpense_Success(t *testing.T) { ID: "exp-correction", UserID: "user-1", Name: "Updated Coffee", - Amount: 600, TransactionCurrency: "USD", - Currency: "USD", ExpenseType: "desires", TagID: "tag-food", ExpenseDate: "2026-05-03", @@ -725,7 +1050,7 @@ func TestCorrectExpense_Success(t *testing.T) { assert.Equal(t, "exp-correction", result.ID) assert.Equal(t, "active", result.Status) assert.Equal(t, "exp-original", result.CorrectsID) - assert.Equal(t, int64(600), result.Amount) + assert.Equal(t, int64(600), result.TransactionAmount) assert.Equal(t, "USD", result.TransactionCurrency) assert.Equal(t, int64(600), result.ReportingAmount) assert.Equal(t, "USD", result.ReportingCurrency) @@ -760,8 +1085,6 @@ func TestCorrectExpense_PeriodLocked(t *testing.T) { ID: "exp-past", UserID: "user-1", Name: "Old Coffee", - Amount: 500, - Currency: "USD", ExpenseType: "desires", TagID: "tag-food", ExpenseDate: "2026-04-15", diff --git a/services/expense/internal/service/fx_client.go b/services/expense/internal/service/fx_client.go new file mode 100644 index 00000000..7aded212 --- /dev/null +++ b/services/expense/internal/service/fx_client.go @@ -0,0 +1,137 @@ +package service + +import ( + "context" + "fmt" + "net/http" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + + "github.com/ItsThompson/gofin/services/apierr" + "github.com/ItsThompson/gofin/services/expense/internal/model" + fxpb "github.com/ItsThompson/gofin/services/fx/proto/fxpb" +) + +// FxConvertRequest is the Expense-service-side view of a conversion request. +// It carries only money facts: no expense name, tag, or user identity. This +// enforces the FX data-minimization rule from the spec. +type FxConvertRequest struct { + Amount int64 + SourceCurrency string + TargetCurrency string + RequestedAt string +} + +// FxConvertResponse mirrors the FX ConvertAmountResponse fields that the +// Expense ledger snapshot needs. Echoed pair/cache fields are deliberately +// omitted: the snapshot only records the converted amount, rate, source, +// timestamp, and expiry. +type FxConvertResponse struct { + ConvertedAmount int64 + ExchangeRate string + RateTimestamp string + Source string + ExpiresAt string +} + +// FxClient converts amounts between currencies using the FX Service. The +// Expense service depends on this interface so tests can inject a mock; the +// gRPC implementation is wired in main.go. +type FxClient interface { + ConvertAmount(ctx context.Context, req FxConvertRequest) (*FxConvertResponse, error) +} + +// GRPCFxClient implements FxClient over the FX Service gRPC API. +type GRPCFxClient struct { + client fxpb.FxServiceClient +} + +// NewGRPCFxClient wraps an existing FX gRPC client. +func NewGRPCFxClient(client fxpb.FxServiceClient) *GRPCFxClient { + return &GRPCFxClient{client: client} +} + +// NewGRPCFxClientFromAddr builds an FX gRPC client and returns the underlying +// connection so the caller owns its lifetime and closes it. The connection uses +// insecure transport because FX is compute-network only. grpc.NewClient is +// lazy, so this only fails for a malformed target. +func NewGRPCFxClientFromAddr(addr string) (*GRPCFxClient, *grpc.ClientConn, error) { + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, nil, fmt.Errorf("creating fx service client for %s: %w", addr, err) + } + return NewGRPCFxClient(fxpb.NewFxServiceClient(conn)), conn, nil +} + +// ConvertAmount calls the FX Service ConvertAmount RPC and maps gRPC status +// errors to the Expense service's safe CONVERSION_UNAVAILABLE error. Per the +// spec error matrix, CONVERSION_UNAVAILABLE, provider auth failure, provider +// response invalid, and missing live rate all map to the same safe REST error +// and must not result in a ledger write. +func (c *GRPCFxClient) ConvertAmount(ctx context.Context, req FxConvertRequest) (*FxConvertResponse, error) { + resp, err := c.client.ConvertAmount(ctx, &fxpb.ConvertAmountRequest{ + Amount: req.Amount, + SourceCurrency: req.SourceCurrency, + TargetCurrency: req.TargetCurrency, + RequestedAt: req.RequestedAt, + }) + if err != nil { + return nil, mapFxError(err) + } + return &FxConvertResponse{ + ConvertedAmount: resp.GetConvertedAmount(), + ExchangeRate: resp.GetExchangeRate(), + RateTimestamp: resp.GetRateTimestamp(), + Source: resp.GetSource(), + ExpiresAt: resp.GetExpiresAt(), + }, nil +} + +// mapFxError maps an FX gRPC failure to the Expense service's REST error while +// preserving the FX error category (spec 05). Expense validates the amount and +// both currencies before calling FX, so InvalidArgument is normally unreachable, +// but the two services deploy independently: catalog or validation skew across a +// rolling deploy must surface as a client error, not a retryable outage. +// +// - Unavailable / FailedPrecondition: CONVERSION_UNAVAILABLE (503), no write. +// - InvalidArgument: 400 UNSUPPORTED_CURRENCY or 400 VALIDATION_ERROR. +// - Internal / unclassified: 500 internal server error, reported as server failure. +// - Non-gRPC transport failure: CONVERSION_UNAVAILABLE (503). +func mapFxError(err error) *apierr.Error { + st, ok := status.FromError(err) + if !ok { + // A non-gRPC transport failure (connection refused, deadline) is a + // conversion outage, not an internal Expense failure. + return conversionUnavailableError() + } + + switch st.Code() { + case codes.Unavailable, codes.FailedPrecondition: + // CONVERSION_UNAVAILABLE, PROVIDER_AUTH_FAILED, + // PROVIDER_RESPONSE_INVALID, and RATE_MISSING for live conversion all + // map to the safe retryable 503. + return conversionUnavailableError() + case codes.InvalidArgument: + // UNSUPPORTED_CURRENCY or INVALID_AMOUNT. Preserve the category so the + // client sees a 400, not a retryable 503. + if st.Message() == "UNSUPPORTED_CURRENCY" { + return &apierr.Error{ + Code: model.ErrUnsupportedCurrency, + Message: "The FX service rejected the currency pair", + Status: http.StatusBadRequest, + Fields: map[string]string{"transactionCurrency": "unsupported currency"}, + } + } + return apierr.Validation("The FX service rejected the conversion amount", nil) + default: + // Internal and anything unclassified is an FX server failure, not a + // retryable conversion outage. The 5xx status makes the handler report + // it as a server error. + return apierr.Internal("currency conversion failed internally") + } +} + +var _ FxClient = (*GRPCFxClient)(nil) diff --git a/services/expense/internal/service/stream_test.go b/services/expense/internal/service/stream_test.go index 29f2429e..e1d6b841 100644 --- a/services/expense/internal/service/stream_test.go +++ b/services/expense/internal/service/stream_test.go @@ -25,8 +25,6 @@ func streamExpense(id, createdAt string) *model.Expense { ID: id, UserID: "user-1", Name: "Expense " + id, - Amount: 1000, - Currency: "USD", ExpenseType: "essentials", TagID: "tag-1", ExpenseDate: "2026-05-01", @@ -207,7 +205,7 @@ func (r *panickingStreamRepo) GetExpensesByUserAfter(context.Context, string, re // errc send it hangs on <-errc until the guard timeout. func TestStreamAllUserExpenses_ProducerPanicTerminatesTheStream(t *testing.T) { logger, logs := serverkittest.NewLogger() - svc := NewExpenseService(&panickingStreamRepo{}, newTestPeriodClient(), time.Now, logger) + svc := NewExpenseService(&panickingStreamRepo{}, newTestPeriodClient(), &stubFxClient{}, time.Now, logger) sendCount := 0 err := collectStream(t, svc, "user-1", 10, func(*model.Expense) error { @@ -262,7 +260,7 @@ func TestStreamAllUserExpenses_ProducerLookAheadBoundedToOnePage(t *testing.T) { page: page, next: repository.ExpenseCursor{CreatedAt: "2026-05-01T00:00:00Z", ID: "exp-9"}, } - svc := NewExpenseService(repo, newTestPeriodClient(), time.Now, slog.New(slog.NewJSONHandler(io.Discard, nil))) + svc := NewExpenseService(repo, newTestPeriodClient(), &stubFxClient{}, time.Now, slog.New(slog.NewJSONHandler(io.Discard, nil))) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/services/expense/internal/service/suggestions.go b/services/expense/internal/service/suggestions.go index fa1f8225..dcb15f49 100644 --- a/services/expense/internal/service/suggestions.go +++ b/services/expense/internal/service/suggestions.go @@ -80,15 +80,15 @@ func (s *ExpenseService) aggregateExpenseSuggestions(inputs []*model.ExpenseSugg frequency := group.nonProRataCount + int32(len(group.proRataGroups)) bucket, weight := recencyBucket(group.latest.CreatedAt, s.clock()) suggestions = append(suggestions, &model.ExpenseSuggestion{ - Name: name, - Amount: group.latest.Amount, - Currency: group.latest.Currency, - ExpenseType: group.latest.ExpenseType, - TagID: group.latest.TagID, - Frequency: frequency, - LastUsedAt: group.latest.CreatedAt, - RecencyBucket: bucket, - FrecencyScore: float64(frequency * weight), + Name: name, + TransactionAmount: group.latest.TransactionAmount, + TransactionCurrency: group.latest.TransactionCurrency, + ExpenseType: group.latest.ExpenseType, + TagID: group.latest.TagID, + Frequency: frequency, + LastUsedAt: group.latest.CreatedAt, + RecencyBucket: bucket, + FrecencyScore: float64(frequency * weight), }) } diff --git a/services/expense/internal/service/suggestions_test.go b/services/expense/internal/service/suggestions_test.go index 82ed88e3..03f8aff6 100644 --- a/services/expense/internal/service/suggestions_test.go +++ b/services/expense/internal/service/suggestions_test.go @@ -19,9 +19,9 @@ func TestGetExpenseSuggestions_AggregatesActiveInputsAndPaginates(t *testing.T) svc := newTestServiceWithClock(repo, time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)) repo.On("GetActiveExpenseSuggestionInputs", mock.Anything, "user-1").Return([]*model.ExpenseSuggestionInput{ - {ID: "exp-1", Name: "Groceries", Amount: 1000, Currency: "USD", ExpenseType: "essentials", TagID: "tag-old", CreatedAt: "2026-05-20T10:00:00Z"}, - {ID: "exp-2", Name: "Groceries", Amount: 1250, Currency: "USD", ExpenseType: "essentials", TagID: "tag-new", CreatedAt: "2026-05-31T10:00:00Z"}, - {ID: "exp-3", Name: "Coffee", Amount: 500, Currency: "USD", ExpenseType: "desires", TagID: "tag-coffee", CreatedAt: "2026-05-01T10:00:00Z"}, + {ID: "exp-1", Name: "Groceries", TransactionAmount: 1000, TransactionCurrency: "USD", ExpenseType: "essentials", TagID: "tag-old", CreatedAt: "2026-05-20T10:00:00Z"}, + {ID: "exp-2", Name: "Groceries", TransactionAmount: 1250, TransactionCurrency: "USD", ExpenseType: "essentials", TagID: "tag-new", CreatedAt: "2026-05-31T10:00:00Z"}, + {ID: "exp-3", Name: "Coffee", TransactionAmount: 500, TransactionCurrency: "USD", ExpenseType: "desires", TagID: "tag-coffee", CreatedAt: "2026-05-01T10:00:00Z"}, }, nil) result, err := svc.GetExpenseSuggestions(context.Background(), &model.ExpenseSuggestionRequest{UserID: "user-1", Page: 1, PageSize: 1}) @@ -33,21 +33,40 @@ func TestGetExpenseSuggestions_AggregatesActiveInputsAndPaginates(t *testing.T) assert.True(t, result.HasMore) require.Len(t, result.Data, 1) assert.Equal(t, "Groceries", result.Data[0].Name) - assert.Equal(t, int64(1250), result.Data[0].Amount) + assert.Equal(t, int64(1250), result.Data[0].TransactionAmount) + assert.Equal(t, "USD", result.Data[0].TransactionCurrency) assert.Equal(t, "tag-new", result.Data[0].TagID) assert.Equal(t, int32(2), result.Data[0].Frequency) assert.Equal(t, "last_7_days", result.Data[0].RecencyBucket) assert.Equal(t, float64(8), result.Data[0].FrecencyScore) } +func TestGetExpenseSuggestions_PreservesForeignTransactionCurrency(t *testing.T) { + repo := new(mockExpenseRepository) + svc := newTestServiceWithClock(repo, time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)) + + repo.On("GetActiveExpenseSuggestionInputs", mock.Anything, "user-1").Return([]*model.ExpenseSuggestionInput{ + {ID: "exp-1", Name: "Hotel", TransactionAmount: 15000, TransactionCurrency: "EUR", ExpenseType: "desires", TagID: "tag-travel", CreatedAt: "2026-05-28T10:00:00Z"}, + {ID: "exp-2", Name: "Hotel", TransactionAmount: 16000, TransactionCurrency: "EUR", ExpenseType: "desires", TagID: "tag-travel", CreatedAt: "2026-05-30T10:00:00Z"}, + }, nil) + + result, err := svc.GetExpenseSuggestions(context.Background(), &model.ExpenseSuggestionRequest{UserID: "user-1", Page: 1, PageSize: 50}) + + require.NoError(t, err) + require.Len(t, result.Data, 1) + // The latest active matching expense determines the suggestion values. + assert.Equal(t, int64(16000), result.Data[0].TransactionAmount) + assert.Equal(t, "EUR", result.Data[0].TransactionCurrency) +} + func TestGetExpenseSuggestions_CountsProRataGroupOnce(t *testing.T) { repo := new(mockExpenseRepository) svc := newTestServiceWithClock(repo, time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)) repo.On("GetActiveExpenseSuggestionInputs", mock.Anything, "user-1").Return([]*model.ExpenseSuggestionInput{ - {ID: "exp-1", Name: "Insurance", Amount: 1000, Currency: "USD", ExpenseType: "essentials", TagID: "tag-ins", CreatedAt: "2026-05-01T10:00:00Z", IsProRata: true, ProRataGroup: "group-1"}, - {ID: "exp-2", Name: "Insurance", Amount: 1000, Currency: "USD", ExpenseType: "essentials", TagID: "tag-ins", CreatedAt: "2026-05-02T10:00:00Z", IsProRata: true, ProRataGroup: "group-1"}, - {ID: "exp-3", Name: "Insurance", Amount: 1000, Currency: "USD", ExpenseType: "essentials", TagID: "tag-ins", CreatedAt: "2026-05-03T10:00:00Z", IsProRata: true}, + {ID: "exp-1", Name: "Insurance", ExpenseType: "essentials", TagID: "tag-ins", CreatedAt: "2026-05-01T10:00:00Z", IsProRata: true, ProRataGroup: "group-1"}, + {ID: "exp-2", Name: "Insurance", ExpenseType: "essentials", TagID: "tag-ins", CreatedAt: "2026-05-02T10:00:00Z", IsProRata: true, ProRataGroup: "group-1"}, + {ID: "exp-3", Name: "Insurance", ExpenseType: "essentials", TagID: "tag-ins", CreatedAt: "2026-05-03T10:00:00Z", IsProRata: true}, }, nil) result, err := svc.GetExpenseSuggestions(context.Background(), &model.ExpenseSuggestionRequest{UserID: "user-1", Page: 1, PageSize: 50}) @@ -62,12 +81,12 @@ func TestGetExpenseSuggestions_RankingTieBreakers(t *testing.T) { svc := newTestServiceWithClock(repo, time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)) repo.On("GetActiveExpenseSuggestionInputs", mock.Anything, "user-1").Return([]*model.ExpenseSuggestionInput{ - {ID: "exp-1", Name: "Coffee", Amount: 500, Currency: "USD", ExpenseType: "desires", TagID: "tag-coffee", CreatedAt: "2026-05-30T10:00:00Z"}, - {ID: "exp-2", Name: "Coffee", Amount: 600, Currency: "USD", ExpenseType: "desires", TagID: "tag-coffee", CreatedAt: "2026-05-30T11:00:00Z"}, - {ID: "exp-3", Name: "Groceries", Amount: 2000, Currency: "USD", ExpenseType: "essentials", TagID: "tag-food", CreatedAt: "2026-05-31T10:00:00Z"}, - {ID: "exp-4", Name: "Transit", Amount: 300, Currency: "USD", ExpenseType: "essentials", TagID: "tag-transit", CreatedAt: "2026-05-29T10:00:00Z"}, - {ID: "exp-5", Name: "Apples", Amount: 700, Currency: "USD", ExpenseType: "essentials", TagID: "tag-food", CreatedAt: "2026-05-28T10:00:00Z"}, - {ID: "exp-6", Name: "Bakery", Amount: 800, Currency: "USD", ExpenseType: "desires", TagID: "tag-bakery", CreatedAt: "2026-05-28T10:00:00Z"}, + {ID: "exp-1", Name: "Coffee", ExpenseType: "desires", TagID: "tag-coffee", CreatedAt: "2026-05-30T10:00:00Z"}, + {ID: "exp-2", Name: "Coffee", ExpenseType: "desires", TagID: "tag-coffee", CreatedAt: "2026-05-30T11:00:00Z"}, + {ID: "exp-3", Name: "Groceries", ExpenseType: "essentials", TagID: "tag-food", CreatedAt: "2026-05-31T10:00:00Z"}, + {ID: "exp-4", Name: "Transit", ExpenseType: "essentials", TagID: "tag-transit", CreatedAt: "2026-05-29T10:00:00Z"}, + {ID: "exp-5", Name: "Apples", ExpenseType: "essentials", TagID: "tag-food", CreatedAt: "2026-05-28T10:00:00Z"}, + {ID: "exp-6", Name: "Bakery", ExpenseType: "desires", TagID: "tag-bakery", CreatedAt: "2026-05-28T10:00:00Z"}, }, nil) result, err := svc.GetExpenseSuggestions(context.Background(), &model.ExpenseSuggestionRequest{UserID: "user-1", Page: 1, PageSize: 50}) @@ -82,15 +101,15 @@ func TestGetExpenseSuggestions_UsesIDTieBreakerForLatestValues(t *testing.T) { svc := newTestServiceWithClock(repo, time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)) repo.On("GetActiveExpenseSuggestionInputs", mock.Anything, "user-1").Return([]*model.ExpenseSuggestionInput{ - {ID: "exp-1", Name: "Lunch", Amount: 1200, Currency: "USD", ExpenseType: "desires", TagID: "tag-old", CreatedAt: "2026-05-31T10:00:00Z"}, - {ID: "exp-2", Name: "Lunch", Amount: 1500, Currency: "USD", ExpenseType: "essentials", TagID: "tag-new", CreatedAt: "2026-05-31T10:00:00Z"}, + {ID: "exp-1", Name: "Lunch", TransactionAmount: 1200, TransactionCurrency: "USD", ExpenseType: "desires", TagID: "tag-old", CreatedAt: "2026-05-31T10:00:00Z"}, + {ID: "exp-2", Name: "Lunch", TransactionAmount: 1500, TransactionCurrency: "USD", ExpenseType: "essentials", TagID: "tag-new", CreatedAt: "2026-05-31T10:00:00Z"}, }, nil) result, err := svc.GetExpenseSuggestions(context.Background(), &model.ExpenseSuggestionRequest{UserID: "user-1", Page: 1, PageSize: 50}) require.NoError(t, err) require.Len(t, result.Data, 1) - assert.Equal(t, int64(1500), result.Data[0].Amount) + assert.Equal(t, int64(1500), result.Data[0].TransactionAmount) assert.Equal(t, "essentials", result.Data[0].ExpenseType) assert.Equal(t, "tag-new", result.Data[0].TagID) } @@ -100,10 +119,10 @@ func TestGetExpenseSuggestions_AssignsRecencyBucketWeights(t *testing.T) { svc := newTestServiceWithClock(repo, time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)) repo.On("GetActiveExpenseSuggestionInputs", mock.Anything, "user-1").Return([]*model.ExpenseSuggestionInput{ - {ID: "exp-1", Name: "Today", Amount: 1000, Currency: "USD", ExpenseType: "essentials", TagID: "tag-1", CreatedAt: "2026-06-01T01:00:00Z"}, - {ID: "exp-2", Name: "Last 7", Amount: 1000, Currency: "USD", ExpenseType: "essentials", TagID: "tag-2", CreatedAt: "2026-05-29T12:00:00Z"}, - {ID: "exp-3", Name: "Last 30", Amount: 1000, Currency: "USD", ExpenseType: "essentials", TagID: "tag-3", CreatedAt: "2026-05-15T12:00:00Z"}, - {ID: "exp-4", Name: "Older", Amount: 1000, Currency: "USD", ExpenseType: "essentials", TagID: "tag-4", CreatedAt: "2026-04-15T12:00:00Z"}, + {ID: "exp-1", Name: "Today", ExpenseType: "essentials", TagID: "tag-1", CreatedAt: "2026-06-01T01:00:00Z"}, + {ID: "exp-2", Name: "Last 7", ExpenseType: "essentials", TagID: "tag-2", CreatedAt: "2026-05-29T12:00:00Z"}, + {ID: "exp-3", Name: "Last 30", ExpenseType: "essentials", TagID: "tag-3", CreatedAt: "2026-05-15T12:00:00Z"}, + {ID: "exp-4", Name: "Older", ExpenseType: "essentials", TagID: "tag-4", CreatedAt: "2026-04-15T12:00:00Z"}, }, nil) result, err := svc.GetExpenseSuggestions(context.Background(), &model.ExpenseSuggestionRequest{UserID: "user-1", Page: 1, PageSize: 50}) @@ -123,8 +142,6 @@ func TestGetExpenseSuggestions_PaginatesRankedSuggestions(t *testing.T) { inputs = append(inputs, &model.ExpenseSuggestionInput{ ID: fmt.Sprintf("exp-%02d", i), Name: fmt.Sprintf("Expense %02d", i), - Amount: int64(1000 + i), - Currency: "USD", ExpenseType: "essentials", TagID: "tag", CreatedAt: "2026-05-31T10:00:00Z", @@ -152,7 +169,7 @@ func TestGetExpenseSuggestions_ReturnsEmptyPageWhenPageStartExceedsTotal(t *test svc := newTestServiceWithClock(repo, time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)) repo.On("GetActiveExpenseSuggestionInputs", mock.Anything, "user-1").Return([]*model.ExpenseSuggestionInput{ - {ID: "exp-1", Name: "Groceries", Amount: 1000, Currency: "USD", ExpenseType: "essentials", TagID: "tag-food", CreatedAt: "2026-05-31T10:00:00Z"}, + {ID: "exp-1", Name: "Groceries", ExpenseType: "essentials", TagID: "tag-food", CreatedAt: "2026-05-31T10:00:00Z"}, }, nil) result, err := svc.GetExpenseSuggestions(context.Background(), &model.ExpenseSuggestionRequest{UserID: "user-1", Page: 2, PageSize: 50}) diff --git a/services/expense/proto/expense.proto b/services/expense/proto/expense.proto index d9784a02..de33f9c0 100644 --- a/services/expense/proto/expense.proto +++ b/services/expense/proto/expense.proto @@ -33,8 +33,7 @@ message ExpenseData { string id = 1; string user_id = 2; string name = 3; - int64 amount = 4; - string currency = 5; + reserved 4, 5; string expense_type = 6; string tag_id = 7; string expense_date = 8; @@ -66,7 +65,7 @@ message CreateExpenseRequest { string user_id = 1; string name = 2; int64 amount = 3; - string currency = 4; + reserved 4; string expense_type = 5; string tag_id = 6; string expense_date = 7; diff --git a/services/expense/proto/expensepb/expense.pb.go b/services/expense/proto/expensepb/expense.pb.go index 61d4aa4f..322d6b27 100644 --- a/services/expense/proto/expensepb/expense.pb.go +++ b/services/expense/proto/expensepb/expense.pb.go @@ -26,8 +26,6 @@ type ExpenseData struct { Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` - Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"` - Currency string `protobuf:"bytes,5,opt,name=currency,proto3" json:"currency,omitempty"` ExpenseType string `protobuf:"bytes,6,opt,name=expense_type,json=expenseType,proto3" json:"expense_type,omitempty"` TagId string `protobuf:"bytes,7,opt,name=tag_id,json=tagId,proto3" json:"tag_id,omitempty"` ExpenseDate string `protobuf:"bytes,8,opt,name=expense_date,json=expenseDate,proto3" json:"expense_date,omitempty"` @@ -104,20 +102,6 @@ func (x *ExpenseData) GetName() string { return "" } -func (x *ExpenseData) GetAmount() int64 { - if x != nil { - return x.Amount - } - return 0 -} - -func (x *ExpenseData) GetCurrency() string { - if x != nil { - return x.Currency - } - return "" -} - func (x *ExpenseData) GetExpenseType() string { if x != nil { return x.ExpenseType @@ -263,7 +247,6 @@ type CreateExpenseRequest struct { UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` Amount int64 `protobuf:"varint,3,opt,name=amount,proto3" json:"amount,omitempty"` - Currency string `protobuf:"bytes,4,opt,name=currency,proto3" json:"currency,omitempty"` ExpenseType string `protobuf:"bytes,5,opt,name=expense_type,json=expenseType,proto3" json:"expense_type,omitempty"` TagId string `protobuf:"bytes,6,opt,name=tag_id,json=tagId,proto3" json:"tag_id,omitempty"` ExpenseDate string `protobuf:"bytes,7,opt,name=expense_date,json=expenseDate,proto3" json:"expense_date,omitempty"` @@ -329,13 +312,6 @@ func (x *CreateExpenseRequest) GetAmount() int64 { return 0 } -func (x *CreateExpenseRequest) GetCurrency() string { - if x != nil { - return x.Currency - } - return "" -} - func (x *CreateExpenseRequest) GetExpenseType() string { if x != nil { return x.ExpenseType @@ -1110,13 +1086,11 @@ var File_proto_expense_proto protoreflect.FileDescriptor const file_proto_expense_proto_rawDesc = "" + "\n" + - "\x13proto/expense.proto\x12\aexpense\"\x8d\a\n" + + "\x13proto/expense.proto\x12\aexpense\"\xe5\x06\n" + "\vExpenseData\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" + "\auser_id\x18\x02 \x01(\tR\x06userId\x12\x12\n" + - "\x04name\x18\x03 \x01(\tR\x04name\x12\x16\n" + - "\x06amount\x18\x04 \x01(\x03R\x06amount\x12\x1a\n" + - "\bcurrency\x18\x05 \x01(\tR\bcurrency\x12!\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12!\n" + "\fexpense_type\x18\x06 \x01(\tR\vexpenseType\x12\x15\n" + "\x06tag_id\x18\a \x01(\tR\x05tagId\x12!\n" + "\fexpense_date\x18\b \x01(\tR\vexpenseDate\x12\x1f\n" + @@ -1140,12 +1114,11 @@ const file_proto_expense_proto_rawDesc = "" + "\rexchange_rate\x18\x17 \x01(\tR\fexchangeRate\x120\n" + "\x14exchange_rate_source\x18\x18 \x01(\tR\x12exchangeRateSource\x126\n" + "\x17exchange_rate_timestamp\x18\x19 \x01(\tR\x15exchangeRateTimestamp\x127\n" + - "\x18exchange_rate_expires_at\x18\x1a \x01(\tR\x15exchangeRateExpiresAt\"\xdd\x03\n" + + "\x18exchange_rate_expires_at\x18\x1a \x01(\tR\x15exchangeRateExpiresAtJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06\"\xc7\x03\n" + "\x14CreateExpenseRequest\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" + - "\x06amount\x18\x03 \x01(\x03R\x06amount\x12\x1a\n" + - "\bcurrency\x18\x04 \x01(\tR\bcurrency\x12!\n" + + "\x06amount\x18\x03 \x01(\x03R\x06amount\x12!\n" + "\fexpense_type\x18\x05 \x01(\tR\vexpenseType\x12\x15\n" + "\x06tag_id\x18\x06 \x01(\tR\x05tagId\x12!\n" + "\fexpense_date\x18\a \x01(\tR\vexpenseDate\x12\x1f\n" + @@ -1157,7 +1130,7 @@ const file_proto_expense_proto_rawDesc = "" + "\x0epro_rata_group\x18\v \x01(\tR\fproRataGroup\x12$\n" + "\x0epro_rata_index\x18\f \x01(\x05R\fproRataIndex\x12$\n" + "\x0epro_rata_total\x18\r \x01(\x05R\fproRataTotal\x121\n" + - "\x14transaction_currency\x18\x0e \x01(\tR\x13transactionCurrency\"A\n" + + "\x14transaction_currency\x18\x0e \x01(\tR\x13transactionCurrencyJ\x04\b\x04\x10\x05\"A\n" + "\x0fExpenseResponse\x12.\n" + "\aexpense\x18\x01 \x01(\v2\x14.expense.ExpenseDataR\aexpense\"\x91\x01\n" + "\x1bGetExpensesForPeriodRequest\x12\x17\n" + diff --git a/services/finance/db/migrations/000006_add_period_reporting_currency.down.sql b/services/finance/db/migrations/000006_add_period_reporting_currency.down.sql index 84fd934b..679aa477 100644 --- a/services/finance/db/migrations/000006_add_period_reporting_currency.down.sql +++ b/services/finance/db/migrations/000006_add_period_reporting_currency.down.sql @@ -1,7 +1,17 @@ +-- Down migration for reporting_currency. +-- +-- WARNING: this down migration removes the reporting_currency column and the +-- migration report table. It does NOT preserve period-currency history: once +-- the column is dropped, the association between each budget period and its +-- reporting currency is lost. Re-running the up migration would re-derive +-- reporting currencies from the current default/auth/fallback sources, which +-- may differ from the values present before the downgrade if defaults changed +-- in the meantime. Proceed only if you accept this data loss. + ALTER TABLE finance.budget_periods DROP CONSTRAINT IF EXISTS budget_periods_reporting_currency_supported; ALTER TABLE finance.budget_periods DROP COLUMN reporting_currency; -DROP TABLE IF EXISTS finance.period_reporting_currency_migration_report; +DROP TABLE IF EXISTS finance.period_reporting_currency_migration_report; \ No newline at end of file diff --git a/services/finance/db/migrations/000006_add_period_reporting_currency.up.sql b/services/finance/db/migrations/000006_add_period_reporting_currency.up.sql index 4021f4fd..a809404a 100644 --- a/services/finance/db/migrations/000006_add_period_reporting_currency.up.sql +++ b/services/finance/db/migrations/000006_add_period_reporting_currency.up.sql @@ -65,11 +65,17 @@ CREATE TABLE finance.period_reporting_currency_migration_report ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +-- Record rows that used auth fallback or app fallback so operators can audit +-- which periods inherited a currency that was not the user's explicit default. INSERT INTO finance.period_reporting_currency_migration_report (period_id, user_id, reporting_currency, reason) -SELECT period.id, period.user_id, backfill.reporting_currency, 'no_supported_default_or_auth_currency' +SELECT period.id, period.user_id, backfill.reporting_currency, + CASE backfill.source + WHEN 'auth_user' THEN 'auth_fallback' + WHEN 'fallback' THEN 'app_fallback' + END FROM finance.budget_periods AS period JOIN period_reporting_currency_backfill AS backfill ON backfill.period_id = period.id -WHERE backfill.source = 'fallback'; +WHERE backfill.source IN ('auth_user', 'fallback'); UPDATE finance.budget_periods AS period SET reporting_currency = backfill.reporting_currency diff --git a/services/finance/db/migrations/migrations_test.go b/services/finance/db/migrations/migrations_test.go new file mode 100644 index 00000000..fcfe9cc1 --- /dev/null +++ b/services/finance/db/migrations/migrations_test.go @@ -0,0 +1,132 @@ +package migrations + +import ( + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ItsThompson/gofin/services/shared/currency" +) + +// TestMigrationReportingCurrencyBackfillPrecedence verifies the up migration +// SQL implements the required fallback source precedence: +// +// valid finance.default_settings.currency +// → valid auth.users.currency +// → configured app fallback currency +// +// and that the migration report records rows that used auth fallback or app +// fallback (ticket #11 AC2, AC11). +func TestMigrationReportingCurrencyBackfillPrecedence(t *testing.T) { + upSQL, err := FS.ReadFile("000006_add_period_reporting_currency.up.sql") + require.NoError(t, err) + sql := string(upSQL) + + // Step 1: finance_default backfill must come before auth_user backfill. + financeDefaultIdx := strings.Index(sql, "'finance_default'") + authUserIdx := strings.Index(sql, "'auth_user'") + require.True(t, financeDefaultIdx >= 0, "migration must have a finance_default source") + require.True(t, authUserIdx >= 0, "migration must have an auth_user source") + assert.Less(t, financeDefaultIdx, authUserIdx, + "finance_default backfill must precede auth_user backfill") + + // Step 2: auth_user backfill must come before app fallback. + fallbackIdx := strings.Index(sql, "'fallback'") + require.True(t, fallbackIdx >= 0, "migration must have a fallback source") + assert.Less(t, authUserIdx, fallbackIdx, + "auth_user backfill must precede app fallback") + + // Step 3: auth_user backfill uses NOT EXISTS to skip rows already backfilled + // from finance defaults (precedence enforcement). + authSection := sql[authUserIdx:fallbackIdx] + assert.Contains(t, authSection, "NOT EXISTS", + "auth_user backfill must skip rows already filled by finance_default") + + // Step 4: fallback also uses NOT EXISTS to skip rows already backfilled. + fallbackSection := sql[fallbackIdx:] + assert.Contains(t, fallbackSection, "NOT EXISTS", + "app fallback must skip rows already filled by finance_default or auth_user") + + // Step 5: migration report records both auth_user and fallback rows. + assert.Contains(t, sql, "'auth_fallback'", + "migration report must record auth fallback rows") + assert.Contains(t, sql, "'app_fallback'", + "migration report must record app fallback rows") + assert.Contains(t, sql, "backfill.source IN ('auth_user', 'fallback')", + "migration report must filter for auth_user and fallback sources") + + // Step 6: validation checks every row has a supported reporting currency. + assert.Contains(t, sql, "RAISE EXCEPTION", + "migration must validate every row has a supported reporting currency") + + // Step 7: NOT NULL constraint is added. + assert.Contains(t, sql, "SET NOT NULL", + "migration must add NOT NULL constraint on reporting_currency") +} + +// TestMigrationReportingCurrencyDownDocumentsHistoryLoss verifies the down +// migration documents that it does not preserve period-currency history +// (ticket #11 AC4). +func TestMigrationReportingCurrencyDownDocumentsHistoryLoss(t *testing.T) { + downSQL, err := FS.ReadFile("000006_add_period_reporting_currency.down.sql") + require.NoError(t, err) + sql := string(downSQL) + + assert.True(t, + strings.Contains(strings.ToUpper(sql), "DOES NOT PRESERVE") || + strings.Contains(strings.ToUpper(sql), "NOT PRESERVE") || + strings.Contains(strings.ToUpper(sql), "DOESN'T PRESERVE") || + strings.Contains(strings.ToUpper(sql), "DATA LOSS") || + strings.Contains(strings.ToUpper(sql), "HISTORY IS LOST") || + strings.Contains(strings.ToUpper(sql), "LOST"), + "down migration must document that it does not preserve period-currency history") + + assert.Contains(t, sql, "DROP COLUMN reporting_currency", + "down migration must drop the reporting_currency column") + assert.Contains(t, sql, "DROP TABLE IF EXISTS finance.period_reporting_currency_migration_report", + "down migration must drop the migration report table") +} + +// TestMigrationReportingCurrencyCodesMatchSharedCatalog asserts the supported- +// currency codes hardcoded in the migration (temp table and CHECK constraint) +// are exactly the codes in the shared currency catalog. The catalog is the +// single source of truth (spec 04); this test flags drift so a future catalog +// change does not silently diverge the migration's validation/CHECK from the +// shared catalog (review S5). +func TestMigrationReportingCurrencyCodesMatchSharedCatalog(t *testing.T) { + upSQL, err := FS.ReadFile("000006_add_period_reporting_currency.up.sql") + require.NoError(t, err) + sql := string(upSQL) + + // All catalog codes are exactly three uppercase letters. Other quoted SQL + // literals ('finance_default', 'auth_user', 'fallback', etc.) are longer and + // do not match this pattern, so the regex isolates the currency code list. + codePattern := regexp.MustCompile(`'([A-Z]{3})'`) + sqlCodes := map[string]bool{} + for _, match := range codePattern.FindAllStringSubmatch(sql, -1) { + sqlCodes[match[1]] = true + } + + catalogCodes := loadCatalogCodes() + + require.Len(t, sqlCodes, len(catalogCodes), + "SQL currency codes must match the shared catalog size") + for code := range catalogCodes { + assert.True(t, sqlCodes[code], "migration SQL missing catalog code %s", code) + } + for code := range sqlCodes { + assert.True(t, catalogCodes[code], "migration SQL contains non-catalog code %s", code) + } +} + +// loadCatalogCodes reads the shared currency catalog codes. +func loadCatalogCodes() map[string]bool { + codes := make(map[string]bool) + for _, definition := range currency.All() { + codes[definition.Code] = true + } + return codes +} diff --git a/services/finance/internal/handler/summary_health_test.go b/services/finance/internal/handler/summary_health_test.go index 4cc18d42..e64f8ef9 100644 --- a/services/finance/internal/handler/summary_health_test.go +++ b/services/finance/internal/handler/summary_health_test.go @@ -36,8 +36,8 @@ func TestGetPeriodSummaryHandler_Success(t *testing.T) { expClient.On("GetExpensesForPeriod", mock.Anything, "user-123", int32(2025), int32(1)). Return([]service.ExpenseData{ - {ID: "e1", Amount: 50000, ReportingAmount: 50000, ExpenseType: "essentials", TagID: "t1", ExpenseDate: "2025-01-05"}, - {ID: "e2", Amount: 20000, ReportingAmount: 20000, ExpenseType: "desires", TagID: "t2", ExpenseDate: "2025-01-10"}, + {ID: "e1", ReportingAmount: 50000, ExpenseType: "essentials", TagID: "t1", ExpenseDate: "2025-01-05"}, + {ID: "e2", ReportingAmount: 20000, ExpenseType: "desires", TagID: "t2", ExpenseDate: "2025-01-10"}, }, nil) r := setupTestRouterWithExpenseClient(repo, txBeginner, expClient) @@ -128,9 +128,9 @@ func TestGetHealthScoreHandler_Success(t *testing.T) { repo.On("ListPeriods", mock.Anything, "user-123").Return([]*model.BudgetPeriod{period}, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-123", int32(2026), int32(5)). Return([]service.ExpenseData{ - {ID: "e1", Amount: 140000, ReportingAmount: 140000, ExpenseType: "essentials", ExpenseDate: "2026-05-05"}, - {ID: "e2", Amount: 80000, ReportingAmount: 80000, ExpenseType: "desires", ExpenseDate: "2026-05-06"}, - {ID: "e3", Amount: 40000, ReportingAmount: 40000, ExpenseType: "savings", ExpenseDate: "2026-05-07"}, + {ID: "e1", ReportingAmount: 140000, ExpenseType: "essentials", ExpenseDate: "2026-05-05"}, + {ID: "e2", ReportingAmount: 80000, ExpenseType: "desires", ExpenseDate: "2026-05-06"}, + {ID: "e3", ReportingAmount: 40000, ExpenseType: "savings", ExpenseDate: "2026-05-07"}, }, nil) // Fixed clock inside May 2026 -> the target month is provisional (computed @@ -306,9 +306,9 @@ func TestGetHistoricalComparisonHandler_Success(t *testing.T) { }, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-123", int32(2026), int32(5)). - Return([]service.ExpenseData{{Amount: 80000, ReportingAmount: 80000}}, nil) + Return([]service.ExpenseData{{ReportingAmount: 80000}}, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-123", int32(2026), int32(4)). - Return([]service.ExpenseData{{Amount: 60000, ReportingAmount: 60000}}, nil) + Return([]service.ExpenseData{{ReportingAmount: 60000}}, nil) r := setupTestRouterWithExpenseClient(repo, txBeginner, expClient) diff --git a/services/finance/internal/handler/tags_spending_test.go b/services/finance/internal/handler/tags_spending_test.go index 8df47fe6..3f8eacb8 100644 --- a/services/finance/internal/handler/tags_spending_test.go +++ b/services/finance/internal/handler/tags_spending_test.go @@ -34,9 +34,9 @@ func TestGetSpendingByTagHandler_Success(t *testing.T) { expClient.On("GetExpensesForPeriod", mock.Anything, "user-123", int32(2025), int32(1)). Return([]service.ExpenseData{ - {TagID: "tag-food", Amount: 30000, ReportingAmount: 30000}, - {TagID: "tag-food", Amount: 20000, ReportingAmount: 20000}, - {TagID: "tag-bills", Amount: 50000, ReportingAmount: 50000}, + {TagID: "tag-food", ReportingAmount: 30000}, + {TagID: "tag-food", ReportingAmount: 20000}, + {TagID: "tag-bills", ReportingAmount: 50000}, }, nil) repo.On("ListTags", mock.Anything, "user-123"). @@ -120,8 +120,8 @@ func TestGetCumulativeSpendHandler_Success(t *testing.T) { expClient.On("GetExpensesForPeriod", mock.Anything, "user-123", int32(2025), int32(1)). Return([]service.ExpenseData{ - {ExpenseDate: "2025-01-01", Amount: 10000, ReportingAmount: 10000}, - {ExpenseDate: "2025-01-03", Amount: 20000, ReportingAmount: 20000}, + {ExpenseDate: "2025-01-01", ReportingAmount: 10000}, + {ExpenseDate: "2025-01-03", ReportingAmount: 20000}, }, nil) r := setupTestRouterWithExpenseClient(repo, txBeginner, expClient) diff --git a/services/finance/internal/model/healthscore.go b/services/finance/internal/model/healthscore.go index 24238456..45976733 100644 --- a/services/finance/internal/model/healthscore.go +++ b/services/finance/internal/model/healthscore.go @@ -4,7 +4,7 @@ package model // to the same numbers only within the same version. It is serialized into the // response so persisted or recomputed scores stay comparable across formula // changes. This version includes the spending-stability sub-score in the weighting. -const FormulaVersion int32 = 2 +const FormulaVersion int32 = 3 // Health-score component keys. These are domain truths shared by the compute // logic, the insight driver, and the frontend, so they live on the model. @@ -51,15 +51,16 @@ type HealthInsight struct { // in [0,100]. ConfigureBudget is set (with the numeric fields left zero) when // the period has no budget configured. type HealthScore struct { - Year int32 `json:"year"` - Month int32 `json:"month"` - Total int32 `json:"total"` - Band string `json:"band"` - Provisional bool `json:"provisional"` - FormulaVersion int32 `json:"formulaVersion"` - Components []HealthComponent `json:"components"` - Insight HealthInsight `json:"insight"` - ConfigureBudget bool `json:"configureBudget,omitempty"` + Year int32 `json:"year"` + Month int32 `json:"month"` + Total int32 `json:"total"` + Band string `json:"band"` + Provisional bool `json:"provisional"` + FormulaVersion int32 `json:"formulaVersion"` + ReportingCurrency string `json:"reportingCurrency"` + Components []HealthComponent `json:"components"` + Insight HealthInsight `json:"insight"` + ConfigureBudget bool `json:"configureBudget,omitempty"` } // HealthScoreResponse is the JSON body returned for GET /api/finance/health-score. @@ -71,12 +72,13 @@ type HealthScoreResponse struct { // carries only the denormalized scalars the sparkline needs, not the full // component breakdown, so the trend read stays cheap. type HealthScoreTrendPoint struct { - Year int32 `json:"year"` - Month int32 `json:"month"` - Total int32 `json:"total"` - Band string `json:"band"` - Provisional bool `json:"provisional"` - FormulaVersion int32 `json:"formulaVersion"` + Year int32 `json:"year"` + Month int32 `json:"month"` + Total int32 `json:"total"` + Band string `json:"band"` + Provisional bool `json:"provisional"` + FormulaVersion int32 `json:"formulaVersion"` + ReportingCurrency string `json:"reportingCurrency"` } // HealthScoreTrendResponse is the JSON body returned for diff --git a/services/finance/internal/model/healthscore_test.go b/services/finance/internal/model/healthscore_test.go index 9709dde6..37692f5f 100644 --- a/services/finance/internal/model/healthscore_test.go +++ b/services/finance/internal/model/healthscore_test.go @@ -100,9 +100,9 @@ func TestHealthScore_ConfigureBudgetShape(t *testing.T) { } } -func TestFormulaVersion_IsTwo(t *testing.T) { - if FormulaVersion != 2 { - t.Errorf("FormulaVersion = %d, want 2 for Phase 2", FormulaVersion) +func TestFormulaVersion_IsThree(t *testing.T) { + if FormulaVersion != 3 { + t.Errorf("FormulaVersion = %d, want 3 for Phase 3", FormulaVersion) } } diff --git a/services/finance/internal/model/requests.go b/services/finance/internal/model/requests.go index 13767944..e61d3584 100644 --- a/services/finance/internal/model/requests.go +++ b/services/finance/internal/model/requests.go @@ -1,6 +1,5 @@ package model -// OnboardingRequest is the input for POST /api/finance/onboarding. // Note: percentage fields intentionally omit binding:"required" because Gin's // validator treats the int32 zero value as "missing", which would reject valid // splits like 100/0/0. The sum-to-100 constraint is enforced by ValidateEDSSplit. @@ -12,12 +11,10 @@ type OnboardingRequest struct { Currency string `json:"currency" binding:"required"` } -// DefaultsResponse is the JSON body returned for default settings endpoints. type DefaultsResponse struct { Defaults *DefaultSettings `json:"defaults"` } -// CreatePeriodRequest is the input for POST /api/finance/periods. // Note: percentage fields intentionally omit binding:"required" because Gin's // validator treats the int32 zero value as "missing", which would reject valid // splits like 100/0/0. The sum-to-100 constraint is enforced by ValidateEDSSplit. @@ -33,37 +30,30 @@ type CreatePeriodRequest struct { SavingsPercent int32 `json:"savingsPercent"` } -// PeriodResponse is the JSON body returned for period endpoints. type PeriodResponse struct { Period *BudgetPeriod `json:"period"` } -// PeriodListResponse is the JSON body returned for listing periods. type PeriodListResponse struct { Periods []*BudgetPeriod `json:"periods"` } -// TagListResponse is the JSON body returned for listing tags. type TagListResponse struct { Tags []*Tag `json:"tags"` } -// CreateTagRequest is the input for POST /api/finance/tags. type CreateTagRequest struct { Name string `json:"name" binding:"required"` } -// TagResponse is the JSON body returned for single tag endpoints. type TagResponse struct { Tag *Tag `json:"tag"` } -// UpdateTagRequest is the input for PUT /api/finance/tags/:id. type UpdateTagRequest struct { Name string `json:"name" binding:"required"` } -// UpdateDefaultsRequest is the input for PUT /api/finance/defaults. // Note: percentage fields intentionally omit binding:"required" because Gin's // validator treats the int32 zero value as "missing", which would reject valid // splits like 100/0/0. The sum-to-100 constraint is enforced by ValidateEDSSplit. @@ -75,22 +65,18 @@ type UpdateDefaultsRequest struct { Currency string `json:"currency" binding:"required"` } -// SummaryResponse is the JSON body returned for GET /api/finance/summary. type SummaryResponse struct { Summary *PeriodSummary `json:"summary"` } -// TagSpendingResponse is the JSON body returned for GET /api/finance/spending/by-tag. type TagSpendingResponse struct { TagSpending []TagSpending `json:"tagSpending"` } -// CumulativeSpendResponse is the JSON body returned for GET /api/finance/spending/cumulative. type CumulativeSpendResponse struct { Points []CumulativeSpendPoint `json:"points"` } -// UpdatePeriodRequest is the input for PUT /api/finance/periods/:id. // Note: percentage fields intentionally omit binding:"required" because Gin's // validator treats the int32 zero value as "missing", which would reject valid // splits like 100/0/0. The sum-to-100 constraint is enforced by ValidateEDSSplit. @@ -101,24 +87,25 @@ type UpdatePeriodRequest struct { SavingsPercent int32 `json:"savingsPercent"` } -// HistoricalComparison is the response for the historical comparison widget. type HistoricalComparison struct { - // CurrentSpent is the total spent in the requested period (cents). - CurrentSpent int64 `json:"currentSpent"` - // PreviousSpent is the total spent in the period before (cents). + CurrentSpent int64 `json:"currentSpent"` PreviousSpent int64 `json:"previousSpent"` - // RollingAverage is the average of the last 3 periods' totalSpent. Null if < 3 periods. + // Empty when there is no previous period. When it differs from the current + // period's reporting currency, ChangePercent and the amount delta are not + // comparable and the frontend must guard the display. + PreviousReportingCurrency string `json:"previousReportingCurrency"` + // Comparable is false when the current and previous periods have different + // reporting currencies, making amount comparisons invalid. + Comparable bool `json:"comparable"` RollingAverage *int64 `json:"rollingAverage"` - // ChangePercent is the percentage change from previous period. + // Only meaningful when Comparable is true. ChangePercent float64 `json:"changePercent"` } -// HistoricalComparisonResponse is the JSON body returned for GET /api/finance/spending/comparison. type HistoricalComparisonResponse struct { Comparison *HistoricalComparison `json:"comparison"` } -// TrendPoint is a single monthly data point in the spending trends chart. type TrendPoint struct { Year int32 `json:"year"` Month int32 `json:"month"` @@ -130,9 +117,11 @@ type TrendPoint struct { EssentialsPercent float64 `json:"essentialsPercent"` DesiresPercent float64 `json:"desiresPercent"` SavingsPercent float64 `json:"savingsPercent"` + // Each trend point may have a different currency when the user has mixed- + // currency periods; the frontend uses it to format each point correctly. + ReportingCurrency string `json:"reportingCurrency"` } -// TrendResponse is the JSON body returned for GET /api/finance/spending/trends. type TrendResponse struct { Trends []TrendPoint `json:"trends"` } diff --git a/services/finance/internal/service/comparison_test.go b/services/finance/internal/service/comparison_test.go index 7b17a25d..8728876b 100644 --- a/services/finance/internal/service/comparison_test.go +++ b/services/finance/internal/service/comparison_test.go @@ -35,13 +35,13 @@ func TestGetHistoricalComparison_WithThreePriorPeriods(t *testing.T) { // Expense totals: current=80000, prev=70000, p3=60000, p2=50000 expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(5)). - Return([]ExpenseData{{Amount: 80000, ReportingAmount: 80000}}, nil) + Return([]ExpenseData{{ReportingAmount: 80000}}, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(4)). - Return([]ExpenseData{{Amount: 70000, ReportingAmount: 70000}}, nil) + Return([]ExpenseData{{ReportingAmount: 70000}}, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(3)). - Return([]ExpenseData{{Amount: 60000, ReportingAmount: 60000}}, nil) + Return([]ExpenseData{{ReportingAmount: 60000}}, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(2)). - Return([]ExpenseData{{Amount: 50000, ReportingAmount: 50000}}, nil) + Return([]ExpenseData{{ReportingAmount: 50000}}, nil) result, err := svc.GetHistoricalComparison(t.Context(), "user-1", 2026, 5) @@ -72,9 +72,9 @@ func TestGetHistoricalComparison_WithTwoPriorPeriods(t *testing.T) { repo.On("ListPeriods", mock.Anything, "user-1").Return(periods, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(3)). - Return([]ExpenseData{{Amount: 50000, ReportingAmount: 50000}}, nil) + Return([]ExpenseData{{ReportingAmount: 50000}}, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(2)). - Return([]ExpenseData{{Amount: 40000, ReportingAmount: 40000}}, nil) + Return([]ExpenseData{{ReportingAmount: 40000}}, nil) result, err := svc.GetHistoricalComparison(t.Context(), "user-1", 2026, 3) @@ -102,7 +102,7 @@ func TestGetHistoricalComparison_OnlyOnePeriod(t *testing.T) { repo.On("ListPeriods", mock.Anything, "user-1").Return(periods, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(1)). - Return([]ExpenseData{{Amount: 30000, ReportingAmount: 30000}}, nil) + Return([]ExpenseData{{ReportingAmount: 30000}}, nil) result, err := svc.GetHistoricalComparison(t.Context(), "user-1", 2026, 1) @@ -129,7 +129,7 @@ func TestGetHistoricalComparison_PreviousZeroSpentCurrentPositive(t *testing.T) repo.On("ListPeriods", mock.Anything, "user-1").Return(periods, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(2)). - Return([]ExpenseData{{Amount: 50000, ReportingAmount: 50000}}, nil) + Return([]ExpenseData{{ReportingAmount: 50000}}, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(1)). Return([]ExpenseData{}, nil) // zero spent @@ -160,9 +160,9 @@ func TestGetHistoricalComparison_DecreaseFromPrevious(t *testing.T) { repo.On("ListPeriods", mock.Anything, "user-1").Return(periods, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(2)). - Return([]ExpenseData{{Amount: 30000, ReportingAmount: 30000}}, nil) + Return([]ExpenseData{{ReportingAmount: 30000}}, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(1)). - Return([]ExpenseData{{Amount: 60000, ReportingAmount: 60000}}, nil) + Return([]ExpenseData{{ReportingAmount: 60000}}, nil) result, err := svc.GetHistoricalComparison(t.Context(), "user-1", 2026, 2) diff --git a/services/finance/internal/service/dashboard.go b/services/finance/internal/service/dashboard.go index 96fb9788..b58056c2 100644 --- a/services/finance/internal/service/dashboard.go +++ b/services/finance/internal/service/dashboard.go @@ -160,6 +160,7 @@ func ComputeSpendingTrends(periods []*model.BudgetPeriod, expensesByMonth [][]Ex point.EssentialsPercent = float64(p.EssentialsPercent) point.DesiresPercent = float64(p.DesiresPercent) point.SavingsPercent = float64(p.SavingsPercent) + point.ReportingCurrency = p.ReportingCurrency } expenses := expensesByMonth[i] @@ -263,21 +264,36 @@ func (s *FinanceService) computeHistoricalComparison( result := &model.HistoricalComparison{ CurrentSpent: spent[0], + Comparable: true, } if len(priorPeriods) > 0 { prevSpent := spent[1] result.PreviousSpent = prevSpent - - if prevSpent > 0 { + result.PreviousReportingCurrency = priorPeriods[0].ReportingCurrency + + // Cross-currency comparison guard: when the previous period's reporting + // currency differs from the current period's, the amount delta and + // change percent are not meaningful and the frontend must guard the display. + currentCurrency := periods[requestedIdx].ReportingCurrency + if currentCurrency != "" && priorPeriods[0].ReportingCurrency != "" && currentCurrency != priorPeriods[0].ReportingCurrency { + result.Comparable = false + result.ChangePercent = 0 + } else if prevSpent > 0 { result.ChangePercent = math.Round(float64(spent[0]-prevSpent)/float64(prevSpent)*10000) / 100 } else if spent[0] > 0 { result.ChangePercent = 100.0 } } - // Rolling average: need at least 3 prior periods - if hasRollingAverage { + // Rolling average: need at least 3 prior periods in the same reporting currency. + // When the prior periods span mixed currencies the average is not meaningful. + if !hasRollingAverage { + return result, nil + } + + currentCurrency := periods[requestedIdx].ReportingCurrency + if allSameReportingCurrency(currentCurrency, priorPeriods[:3]) { avg := (spent[1] + spent[2] + spent[3]) / 3 result.RollingAverage = &avg } @@ -525,3 +541,17 @@ func parseDayForPeriod(date string, periodYear, periodMonth int32) int32 { } return int32(t.Day()) } + +// allSameReportingCurrency reports whether every period has the same non-empty +// reporting currency as current. +func allSameReportingCurrency(current string, periods []*model.BudgetPeriod) bool { + if current == "" { + return false + } + for _, p := range periods { + if p.ReportingCurrency != current { + return false + } + } + return true +} diff --git a/services/finance/internal/service/dashboard_bench_test.go b/services/finance/internal/service/dashboard_bench_test.go index 0d0a025e..fcd202a2 100644 --- a/services/finance/internal/service/dashboard_bench_test.go +++ b/services/finance/internal/service/dashboard_bench_test.go @@ -32,8 +32,8 @@ func seedYearPeriods(exp *countingExpenseClient) []*model.BudgetPeriod { SavingsPercent: 20, }) exp.set(2026, m, []ExpenseData{ - {Amount: int64(m) * 1000, ReportingAmount: int64(m) * 1000, ExpenseType: "essentials"}, - {Amount: int64(m) * 500, ReportingAmount: int64(m) * 500, ExpenseType: "desires"}, + {ReportingAmount: int64(m) * 1000, ExpenseType: "essentials"}, + {ReportingAmount: int64(m) * 500, ExpenseType: "desires"}, }) } return periods diff --git a/services/finance/internal/service/dashboard_calc_test.go b/services/finance/internal/service/dashboard_calc_test.go index 086a5c37..a7b79a3d 100644 --- a/services/finance/internal/service/dashboard_calc_test.go +++ b/services/finance/internal/service/dashboard_calc_test.go @@ -95,9 +95,9 @@ func TestComputePeriodSummary_BasicScenario(t *testing.T) { } expenses := []ExpenseData{ - {ID: "e1", Amount: 50000, ReportingAmount: 50000, ExpenseType: "essentials", TagID: "t1", ExpenseDate: "2025-01-05"}, - {ID: "e2", Amount: 20000, ReportingAmount: 20000, ExpenseType: "desires", TagID: "t2", ExpenseDate: "2025-01-06"}, - {ID: "e3", Amount: 10000, ReportingAmount: 10000, ExpenseType: "savings", TagID: "t3", ExpenseDate: "2025-01-07"}, + {ID: "e1", ReportingAmount: 50000, ExpenseType: "essentials", TagID: "t1", ExpenseDate: "2025-01-05"}, + {ID: "e2", ReportingAmount: 20000, ExpenseType: "desires", TagID: "t2", ExpenseDate: "2025-01-06"}, + {ID: "e3", ReportingAmount: 10000, ExpenseType: "savings", TagID: "t3", ExpenseDate: "2025-01-07"}, } summary := ComputePeriodSummary(period, expenses, 2025, 1, historicalNow) @@ -147,7 +147,7 @@ func TestComputePeriodSummary_OverBudget(t *testing.T) { } expenses := []ExpenseData{ - {ID: "e1", Amount: 120000, ReportingAmount: 120000, ExpenseType: "essentials", TagID: "t1", ExpenseDate: "2025-02-15"}, + {ID: "e1", ReportingAmount: 120000, ExpenseType: "essentials", TagID: "t1", ExpenseDate: "2025-02-15"}, } summary := ComputePeriodSummary(period, expenses, 2025, 2, historicalNow) @@ -188,7 +188,7 @@ func TestComputePeriodSummary_PacingCalculation(t *testing.T) { // Spend $1500 in first 15 days → $100/day rate → exactly on track expenses := []ExpenseData{ - {ID: "e1", Amount: 150000, ReportingAmount: 150000, ExpenseType: "essentials", TagID: "t1", ExpenseDate: "2025-01-15"}, + {ID: "e1", ReportingAmount: 150000, ExpenseType: "essentials", TagID: "t1", ExpenseDate: "2025-01-15"}, } // Historical month: all 31 days elapsed @@ -206,10 +206,10 @@ func TestComputePeriodSummary_PacingCalculation(t *testing.T) { func TestComputeTagSpending_MultipleTagsSorted(t *testing.T) { expenses := []ExpenseData{ - {TagID: "food", Amount: 30000, ReportingAmount: 30000}, - {TagID: "food", Amount: 20000, ReportingAmount: 20000}, - {TagID: "transport", Amount: 10000, ReportingAmount: 10000}, - {TagID: "bills", Amount: 40000, ReportingAmount: 40000}, + {TagID: "food", ReportingAmount: 30000}, + {TagID: "food", ReportingAmount: 20000}, + {TagID: "transport", ReportingAmount: 10000}, + {TagID: "bills", ReportingAmount: 40000}, } tagNames := map[string]string{ @@ -242,7 +242,7 @@ func TestComputeTagSpending_NoExpenses(t *testing.T) { func TestComputeTagSpending_UnknownTag(t *testing.T) { expenses := []ExpenseData{ - {TagID: "deleted-tag", Amount: 5000, ReportingAmount: 5000}, + {TagID: "deleted-tag", ReportingAmount: 5000}, } result := ComputeTagSpending(expenses, map[string]string{}) @@ -254,9 +254,9 @@ func TestComputeTagSpending_UnknownTag(t *testing.T) { func TestComputeCumulativeSpend_BasicAccumulation(t *testing.T) { expenses := []ExpenseData{ - {ExpenseDate: "2025-01-01", Amount: 10000, ReportingAmount: 10000}, - {ExpenseDate: "2025-01-01", Amount: 5000, ReportingAmount: 5000}, - {ExpenseDate: "2025-01-03", Amount: 20000, ReportingAmount: 20000}, + {ExpenseDate: "2025-01-01", ReportingAmount: 10000}, + {ExpenseDate: "2025-01-01", ReportingAmount: 5000}, + {ExpenseDate: "2025-01-03", ReportingAmount: 20000}, } points := ComputeCumulativeSpend(expenses, 310000, 2025, 1, 31) @@ -294,7 +294,7 @@ func TestComputeCumulativeSpend_NoExpenses(t *testing.T) { func TestComputeCumulativeSpend_DayCarryForward(t *testing.T) { expenses := []ExpenseData{ - {ExpenseDate: "2025-03-10", Amount: 50000, ReportingAmount: 50000}, + {ExpenseDate: "2025-03-10", ReportingAmount: 50000}, } points := ComputeCumulativeSpend(expenses, 300000, 2025, 3, 31) @@ -312,8 +312,8 @@ func TestComputeCumulativeSpend_DayCarryForward(t *testing.T) { func TestComputeCumulativeSpend_CrossMonthClamp(t *testing.T) { // April 29 expense assigned to May period should be clamped to day 1 expenses := []ExpenseData{ - {ExpenseDate: "2025-04-29", Amount: 15000, ReportingAmount: 15000}, - {ExpenseDate: "2025-05-03", Amount: 20000, ReportingAmount: 20000}, + {ExpenseDate: "2025-04-29", ReportingAmount: 15000}, + {ExpenseDate: "2025-05-03", ReportingAmount: 20000}, } points := ComputeCumulativeSpend(expenses, 300000, 2025, 5, 31) @@ -377,7 +377,7 @@ func TestComputePeriodSummary_CurrentMonthPacing(t *testing.T) { } expenses := []ExpenseData{ - {ID: "e1", Amount: 100000, ReportingAmount: 100000, ExpenseType: "essentials", TagID: "t1", ExpenseDate: "2025-01-05"}, + {ID: "e1", ReportingAmount: 100000, ExpenseType: "essentials", TagID: "t1", ExpenseDate: "2025-01-05"}, } // Simulate being on Jan 10: 10 days elapsed, spent 100000 @@ -397,8 +397,7 @@ func TestComputePeriodSummary_CurrentMonthPacing(t *testing.T) { // TestComputePeriodSummary_SumsReportingAmountNotLegacyAmount asserts the // dashboard aggregates each expense's ReportingAmount (the period-reporting -// money) rather than the legacy Amount, so mixed-currency periods total in one -// currency. When ReportingAmount is set it must win even if it differs from Amount. +// money), so mixed-currency periods total in one currency. func TestComputePeriodSummary_SumsReportingAmountNotLegacyAmount(t *testing.T) { period := &model.BudgetPeriod{ ID: "period-1", @@ -408,12 +407,11 @@ func TestComputePeriodSummary_SumsReportingAmountNotLegacyAmount(t *testing.T) { SavingsPercent: 20, } - // Two expenses whose legacy Amount differs from their ReportingAmount (a - // foreign-currency row converted into the period reporting currency). The - // dashboard must sum ReportingAmount (200000), not Amount (50000). + // Two foreign-currency rows converted into the period reporting currency. + // The dashboard must sum ReportingAmount (200000). expenses := []ExpenseData{ - {ID: "e1", Amount: 25000, ReportingAmount: 90000, ReportingCurrency: "USD", ExpenseType: "essentials", TagID: "t1", ExpenseDate: "2025-01-05"}, - {ID: "e2", Amount: 25000, ReportingAmount: 110000, ReportingCurrency: "USD", ExpenseType: "desires", TagID: "t2", ExpenseDate: "2025-01-06"}, + {ID: "e1", ReportingAmount: 90000, ReportingCurrency: "USD", ExpenseType: "essentials", TagID: "t1", ExpenseDate: "2025-01-05"}, + {ID: "e2", ReportingAmount: 110000, ReportingCurrency: "USD", ExpenseType: "desires", TagID: "t2", ExpenseDate: "2025-01-06"}, } summary := ComputePeriodSummary(period, expenses, 2025, 1, historicalNow) @@ -425,9 +423,9 @@ func TestComputePeriodSummary_SumsReportingAmountNotLegacyAmount(t *testing.T) { } // TestComputePeriodSummary_MissingReportingAmountAggregatesZero asserts that a -// row carrying only the legacy Amount no longer contributes to the dashboard -// total. ReportingAmount is the only aggregated money field, so a forgotten -// field fails loud (zero) instead of silently aggregating transaction currency. +// row with no ReportingAmount contributes zero to the dashboard total. +// ReportingAmount is the only aggregated money field, so a forgotten field +// fails loud (zero) instead of silently aggregating transaction currency. func TestComputePeriodSummary_MissingReportingAmountAggregatesZero(t *testing.T) { period := &model.BudgetPeriod{ ID: "period-1", @@ -437,8 +435,8 @@ func TestComputePeriodSummary_MissingReportingAmountAggregatesZero(t *testing.T) SavingsPercent: 20, } expenses := []ExpenseData{ - {ID: "e1", Amount: 50000, ExpenseType: "essentials", TagID: "t1", ExpenseDate: "2025-01-05"}, - {ID: "e2", Amount: 30000, ExpenseType: "desires", TagID: "t2", ExpenseDate: "2025-01-06"}, + {ID: "e1", ExpenseType: "essentials", TagID: "t1", ExpenseDate: "2025-01-05"}, + {ID: "e2", ExpenseType: "desires", TagID: "t2", ExpenseDate: "2025-01-06"}, } summary := ComputePeriodSummary(period, expenses, 2025, 1, historicalNow) diff --git a/services/finance/internal/service/expense_client.go b/services/finance/internal/service/expense_client.go index 7bee3642..64d6f1e0 100644 --- a/services/finance/internal/service/expense_client.go +++ b/services/finance/internal/service/expense_client.go @@ -5,7 +5,6 @@ import "context" // ExpenseData holds the fields the finance service needs from an expense record. type ExpenseData struct { ID string - Amount int64 // Legacy amount column; kept for backward compatibility. ReportingAmount int64 // Converted amount in the period reporting currency minor units. ReportingCurrency string // Budget period reporting currency for this row. ExpenseType string // "essentials", "desires", "savings" diff --git a/services/finance/internal/service/fanout_panic_test.go b/services/finance/internal/service/fanout_panic_test.go index e2e17256..c937cdda 100644 --- a/services/finance/internal/service/fanout_panic_test.go +++ b/services/finance/internal/service/fanout_panic_test.go @@ -75,7 +75,7 @@ func (c *panickingExpenseClient) GetExpensesForPeriod(_ context.Context, _ strin if (c.panicYear == 0 && c.panicMonth == 0) || (year == c.panicYear && month == c.panicMonth) { explodeIn("expense read") } - return []ExpenseData{{Amount: 1000, ReportingAmount: 1000, ExpenseType: "desires"}}, nil + return []ExpenseData{{ReportingAmount: 1000, ExpenseType: "desires"}}, nil } // requireOnePanicRecord asserts the sink holds exactly one error-level record diff --git a/services/finance/internal/service/grpc_expense_client.go b/services/finance/internal/service/grpc_expense_client.go index 8048fd6d..e701a77b 100644 --- a/services/finance/internal/service/grpc_expense_client.go +++ b/services/finance/internal/service/grpc_expense_client.go @@ -33,7 +33,6 @@ func (c *GRPCExpenseClient) GetExpensesForPeriod(ctx context.Context, userID str for i, exp := range resp.GetData() { expenses[i] = ExpenseData{ ID: exp.GetId(), - Amount: exp.GetAmount(), ReportingAmount: exp.GetReportingAmount(), ReportingCurrency: exp.GetReportingCurrency(), ExpenseType: exp.GetExpenseType(), diff --git a/services/finance/internal/service/grpc_expense_client_test.go b/services/finance/internal/service/grpc_expense_client_test.go index d9d5ac8e..2135fbd5 100644 --- a/services/finance/internal/service/grpc_expense_client_test.go +++ b/services/finance/internal/service/grpc_expense_client_test.go @@ -33,7 +33,6 @@ func TestGRPCExpenseClient_ReadsReportingAmountAndCurrency(t *testing.T) { Data: []*expensepb.ExpenseData{ { Id: "e1", - Amount: 25000, // legacy amount in transaction currency ReportingAmount: 90000, // converted reporting amount ReportingCurrency: "USD", ExpenseType: "essentials", @@ -42,7 +41,6 @@ func TestGRPCExpenseClient_ReadsReportingAmountAndCurrency(t *testing.T) { }, { Id: "e2", - Amount: 25000, ReportingAmount: 110000, ReportingCurrency: "USD", ExpenseType: "desires", @@ -62,7 +60,6 @@ func TestGRPCExpenseClient_ReadsReportingAmountAndCurrency(t *testing.T) { assert.Equal(t, int64(90000), expenses[0].ReportingAmount) assert.Equal(t, "USD", expenses[0].ReportingCurrency) - assert.Equal(t, int64(25000), expenses[0].Amount) // legacy amount preserved assert.Equal(t, int64(110000), expenses[1].ReportingAmount) assert.Equal(t, "USD", expenses[1].ReportingCurrency) diff --git a/services/finance/internal/service/healthscore.go b/services/finance/internal/service/healthscore.go index ff6cdf44..4fbdcdfd 100644 --- a/services/finance/internal/service/healthscore.go +++ b/services/finance/internal/service/healthscore.go @@ -222,13 +222,14 @@ func ComputeHealthScore(period *model.BudgetPeriod, expenses []ExpenseData, desi }) return &model.HealthScore{ - Year: year, - Month: month, - Total: total, - Band: model.Band(total), - Provisional: isProvisional(year, month, now), - FormulaVersion: model.FormulaVersion, - Components: components, - Insight: insight, + Year: year, + Month: month, + Total: total, + Band: model.Band(total), + Provisional: isProvisional(year, month, now), + FormulaVersion: model.FormulaVersion, + ReportingCurrency: currency, + Components: components, + Insight: insight, } } diff --git a/services/finance/internal/service/healthscore_service.go b/services/finance/internal/service/healthscore_service.go index f865471a..f2b08382 100644 --- a/services/finance/internal/service/healthscore_service.go +++ b/services/finance/internal/service/healthscore_service.go @@ -20,7 +20,7 @@ func (s *FinanceService) GetHealthScore(ctx context.Context, userID string, year return nil, err } if period.BudgetAmount == 0 { - return &model.HealthScore{Year: year, Month: month, ConfigureBudget: true}, nil + return &model.HealthScore{Year: year, Month: month, ConfigureBudget: true, ReportingCurrency: period.ReportingCurrency}, nil } return s.resolveHealthScore(ctx, userID, period, year, month) } @@ -42,6 +42,11 @@ func (s *FinanceService) resolveHealthScore(ctx context.Context, userID string, return nil, fmt.Errorf("getting stored health score: %w", err) } if stored != nil && stored.FormulaVersion == model.FormulaVersion { + // Backfill ReportingCurrency for scores persisted before the field was + // added. The period's reporting currency is immutable, so this is safe. + if stored.ReportingCurrency == "" { + stored.ReportingCurrency = period.ReportingCurrency + } return stored, nil } @@ -64,16 +69,14 @@ func (s *FinanceService) resolveHealthScore(ctx context.Context, userID string, } // computeHealthScore reads the inputs the pure ComputeHealthScore needs: the -// user currency, the target month's expenses, and the desires window that feeds -// the stability sub-score. +// target month's expenses and the desires window that feeds the stability +// sub-score. The insight currency is the scored period's immutable reporting +// currency, not the user's default settings currency, so the display stays +// stable after default settings changes. func (s *FinanceService) computeHealthScore(ctx context.Context, userID string, period *model.BudgetPeriod, year, month int32) (*model.HealthScore, error) { - currency := defaultCurrency - defaults, err := s.repo.GetDefaults(ctx, userID) - if err != nil { - return nil, fmt.Errorf("getting defaults: %w", err) - } - if defaults != nil && defaults.Currency != "" { - currency = defaults.Currency + currency := period.ReportingCurrency + if currency == "" { + currency = defaultCurrency } expenses, err := s.expenseClient.GetExpensesForPeriod(ctx, userID, year, month) diff --git a/services/finance/internal/service/healthscore_service_test.go b/services/finance/internal/service/healthscore_service_test.go index 5428e425..4704f52c 100644 --- a/services/finance/internal/service/healthscore_service_test.go +++ b/services/finance/internal/service/healthscore_service_test.go @@ -21,6 +21,7 @@ func healthPeriodMonth(year, month int32) *model.BudgetPeriod { return &model.BudgetPeriod{ ID: "p", UserID: "user-1", Year: year, Month: month, BudgetAmount: 300000, EssentialsPercent: 50, DesiresPercent: 30, SavingsPercent: 20, + ReportingCurrency: "USD", } } @@ -32,7 +33,7 @@ func TestGetHealthScore_ConfigureBudget(t *testing.T) { svc := newTagTestServiceNow(repo, txBeg, expClient, func() time.Time { return currentMonthNow }) repo.On("GetCurrentPeriod", mock.Anything, "user-1", int32(2026), int32(5)). - Return(&model.BudgetPeriod{ID: "p0", UserID: "user-1", Year: 2026, Month: 5, BudgetAmount: 0}, nil) + Return(&model.BudgetPeriod{ID: "p0", UserID: "user-1", Year: 2026, Month: 5, BudgetAmount: 0, ReportingCurrency: "USD"}, nil) score, err := svc.GetHealthScore(t.Context(), "user-1", 2026, 5) @@ -41,22 +42,23 @@ func TestGetHealthScore_ConfigureBudget(t *testing.T) { assert.Empty(t, score.Components) assert.Equal(t, int32(2026), score.Year) assert.Equal(t, int32(5), score.Month) + assert.Equal(t, "USD", score.ReportingCurrency) // Must not read defaults, expenses, or storage when there is no budget. repo.AssertNotCalled(t, "GetDefaults", mock.Anything, mock.Anything) repo.AssertNotCalled(t, "GetHealthScore", mock.Anything, mock.Anything, mock.Anything, mock.Anything) expClient.AssertNotCalled(t, "GetExpensesForPeriod", mock.Anything, mock.Anything, mock.Anything, mock.Anything) } -func TestGetHealthScore_Success_UsesDefaultsCurrency(t *testing.T) { +func TestGetHealthScore_Success_UsesPeriodReportingCurrency(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) expClient := new(mockExpClient) svc := newTagTestServiceNow(repo, txBeg, expClient, func() time.Time { return currentMonthNow }) + eurPeriod := healthPeriod(300000, 50, 30, 20) + eurPeriod.ReportingCurrency = "EUR" repo.On("GetCurrentPeriod", mock.Anything, "user-1", int32(2026), int32(5)). - Return(healthPeriod(300000, 50, 30, 20), nil) - repo.On("GetDefaults", mock.Anything, "user-1"). - Return(&model.DefaultSettings{UserID: "user-1", Currency: "EUR"}, nil) + Return(eurPeriod, nil) // Only the current (provisional) period exists, so the stability window is // empty (building baseline). repo.On("ListPeriods", mock.Anything, "user-1"). @@ -73,22 +75,27 @@ func TestGetHealthScore_Success_UsesDefaultsCurrency(t *testing.T) { require.NoError(t, err) assert.Equal(t, int32(79), score.Total) assert.Equal(t, model.HealthKeySavings, score.Insight.Driver) - // Currency from defaults drives the money symbol in the nudge. + // Currency comes from the period's reporting currency, not default settings. + assert.Equal(t, "EUR", score.ReportingCurrency) assert.Contains(t, score.Insight.Nudge, "€300") + // Must not read defaults: the period reporting currency is authoritative. + repo.AssertNotCalled(t, "GetDefaults", mock.Anything, mock.Anything) // The provisional current month is never stored. repo.AssertNotCalled(t, "UpsertHealthScore", mock.Anything, mock.Anything, mock.Anything) } -func TestGetHealthScore_DefaultsCurrencyFallback(t *testing.T) { - // nil defaults -> currency falls back to USD. +func TestGetHealthScore_EmptyReportingCurrencyFallback(t *testing.T) { + // A period with no reporting currency (edge case for pre-migration data) + // falls back to USD. repo := new(mockRepo) txBeg := new(mockTxBeg) expClient := new(mockExpClient) svc := newTagTestServiceNow(repo, txBeg, expClient, func() time.Time { return currentMonthNow }) + period := healthPeriod(300000, 50, 30, 20) + period.ReportingCurrency = "" repo.On("GetCurrentPeriod", mock.Anything, "user-1", int32(2026), int32(5)). - Return(healthPeriod(300000, 50, 30, 20), nil) - repo.On("GetDefaults", mock.Anything, "user-1").Return(nil, nil) + Return(period, nil) repo.On("ListPeriods", mock.Anything, "user-1"). Return([]*model.BudgetPeriod{healthPeriodMonth(2026, 5)}, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(5)). @@ -102,6 +109,7 @@ func TestGetHealthScore_DefaultsCurrencyFallback(t *testing.T) { require.NoError(t, err) assert.Contains(t, score.Insight.Nudge, "$300") + assert.Equal(t, "USD", score.ReportingCurrency) } func TestGetHealthScore_PeriodNotFound(t *testing.T) { @@ -166,6 +174,27 @@ func TestGetHealthScore_ClosedStoredCurrentVersionReturnsStored(t *testing.T) { repo.AssertNotCalled(t, "UpsertHealthScore", mock.Anything, mock.Anything, mock.Anything) } +func TestGetHealthScore_ClosedStoredBackfillsReportingCurrency(t *testing.T) { + // A stored score persisted before reportingCurrency was added gets backfilled + // from the period's immutable reporting currency. + repo := new(mockRepo) + txBeg := new(mockTxBeg) + expClient := new(mockExpClient) + svc := newTagTestServiceNow(repo, txBeg, expClient, func() time.Time { return nowJuly }) + + stored := storedScore(model.FormulaVersion) + stored.ReportingCurrency = "" // persisted before the field existed + repo.On("GetCurrentPeriod", mock.Anything, "user-1", int32(2026), int32(5)). + Return(healthPeriod(300000, 50, 30, 20), nil) + repo.On("GetHealthScore", mock.Anything, "user-1", int32(2026), int32(5)). + Return(stored, nil) + + score, err := svc.GetHealthScore(t.Context(), "user-1", 2026, 5) + + require.NoError(t, err) + assert.Equal(t, "USD", score.ReportingCurrency, "backfilled from period reporting currency") +} + func TestGetHealthScore_ClosedMissComputesAndUpserts(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) @@ -175,7 +204,6 @@ func TestGetHealthScore_ClosedMissComputesAndUpserts(t *testing.T) { repo.On("GetCurrentPeriod", mock.Anything, "user-1", int32(2026), int32(5)). Return(healthPeriod(300000, 50, 30, 20), nil) repo.On("GetHealthScore", mock.Anything, "user-1", int32(2026), int32(5)).Return(nil, nil) // miss - repo.On("GetDefaults", mock.Anything, "user-1").Return(&model.DefaultSettings{Currency: "USD"}, nil) repo.On("ListPeriods", mock.Anything, "user-1"). Return([]*model.BudgetPeriod{healthPeriodMonth(2026, 5)}, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(5)). @@ -187,6 +215,7 @@ func TestGetHealthScore_ClosedMissComputesAndUpserts(t *testing.T) { require.NoError(t, err) assert.Equal(t, model.FormulaVersion, score.FormulaVersion) assert.False(t, score.Provisional, "a closed month is not provisional") + assert.Equal(t, "USD", score.ReportingCurrency) repo.AssertCalled(t, "UpsertHealthScore", mock.Anything, "user-1", mock.Anything) } @@ -201,7 +230,6 @@ func TestGetHealthScore_StaleVersionRecomputesAndUpserts(t *testing.T) { Return(healthPeriod(300000, 50, 30, 20), nil) repo.On("GetHealthScore", mock.Anything, "user-1", int32(2026), int32(5)). Return(storedScore(1), nil) // stale version - repo.On("GetDefaults", mock.Anything, "user-1").Return(&model.DefaultSettings{Currency: "USD"}, nil) repo.On("ListPeriods", mock.Anything, "user-1"). Return([]*model.BudgetPeriod{healthPeriodMonth(2026, 5)}, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(5)). @@ -225,7 +253,6 @@ func TestGetHealthScore_ProvisionalNotStored(t *testing.T) { repo.On("GetCurrentPeriod", mock.Anything, "user-1", int32(2026), int32(5)). Return(healthPeriod(300000, 50, 30, 20), nil) - repo.On("GetDefaults", mock.Anything, "user-1").Return(&model.DefaultSettings{Currency: "USD"}, nil) repo.On("ListPeriods", mock.Anything, "user-1"). Return([]*model.BudgetPeriod{healthPeriodMonth(2026, 5)}, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(5)). @@ -251,7 +278,6 @@ func TestGetHealthScore_StabilityPresentReadsWindowOncePerMonth(t *testing.T) { repo.On("GetCurrentPeriod", mock.Anything, "user-1", int32(2026), int32(5)). Return(healthPeriod(300000, 50, 30, 20), nil) - repo.On("GetDefaults", mock.Anything, "user-1").Return(&model.DefaultSettings{Currency: "USD"}, nil) // DESC order: current May (provisional, excluded), then Apr..Jan closed. repo.On("ListPeriods", mock.Anything, "user-1").Return([]*model.BudgetPeriod{ healthPeriodMonth(2026, 5), @@ -280,3 +306,35 @@ func TestGetHealthScore_StabilityPresentReadsWindowOncePerMonth(t *testing.T) { // one read for the target plus one per windowed month (Apr..Jan) = 5. expClient.AssertNumberOfCalls(t, "GetExpensesForPeriod", 5) } + +// --- Health-score currency stability --- + +func TestGetHealthScore_StableAfterDefaultCurrencyChange(t *testing.T) { + // A period created with USD reporting currency must keep its USD health-score + // display even after the user changes their default settings currency to EUR. + // The score is computed from the period's immutable reporting currency, not + // from default settings. + repo := new(mockRepo) + txBeg := new(mockTxBeg) + expClient := new(mockExpClient) + svc := newTagTestServiceNow(repo, txBeg, expClient, func() time.Time { return currentMonthNow }) + + repo.On("GetCurrentPeriod", mock.Anything, "user-1", int32(2026), int32(5)). + Return(healthPeriod(300000, 50, 30, 20), nil) // reportingCurrency = USD + repo.On("ListPeriods", mock.Anything, "user-1"). + Return([]*model.BudgetPeriod{healthPeriodMonth(2026, 5)}, nil) + expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(5)). + Return([]ExpenseData{ + healthExpense("essentials", 130000), + healthExpense("desires", 90000), + healthExpense("savings", 30000), + }, nil) + + score, err := svc.GetHealthScore(t.Context(), "user-1", 2026, 5) + + require.NoError(t, err) + assert.Equal(t, "USD", score.ReportingCurrency) + assert.Contains(t, score.Insight.Nudge, "$300") + // Default settings are never read for currency resolution. + repo.AssertNotCalled(t, "GetDefaults", mock.Anything, mock.Anything) +} diff --git a/services/finance/internal/service/healthscore_snapshot_test.go b/services/finance/internal/service/healthscore_snapshot_test.go index d3801ba1..7fad6ad2 100644 --- a/services/finance/internal/service/healthscore_snapshot_test.go +++ b/services/finance/internal/service/healthscore_snapshot_test.go @@ -39,11 +39,12 @@ func canonicalHealthScore() *model.HealthScore { period := &model.BudgetPeriod{ Year: 2026, Month: 3, BudgetAmount: 300000, EssentialsPercent: 50, DesiresPercent: 30, SavingsPercent: 20, + ReportingCurrency: "USD", } expenses := []ExpenseData{ - {ExpenseType: "essentials", Amount: 140000, ReportingAmount: 140000}, - {ExpenseType: "desires", Amount: 95000, ReportingAmount: 95000}, - {ExpenseType: "savings", Amount: 30000, ReportingAmount: 30000}, + {ExpenseType: "essentials", ReportingAmount: 140000}, + {ExpenseType: "desires", ReportingAmount: 95000}, + {ExpenseType: "savings", ReportingAmount: 30000}, } desiresWindow := []int64{60000, 90000, 120000, 80000} closedNow := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) // March 2026 is closed diff --git a/services/finance/internal/service/healthscore_test.go b/services/finance/internal/service/healthscore_test.go index 7c24e39a..6c5aef7a 100644 --- a/services/finance/internal/service/healthscore_test.go +++ b/services/finance/internal/service/healthscore_test.go @@ -15,11 +15,12 @@ func healthPeriod(budget int64, essentials, desires, savings int32) *model.Budge return &model.BudgetPeriod{ ID: "p-health", UserID: "user-1", Year: 2026, Month: 5, BudgetAmount: budget, EssentialsPercent: essentials, DesiresPercent: desires, SavingsPercent: savings, + ReportingCurrency: "USD", } } func healthExpense(expenseType string, amount int64) ExpenseData { - return ExpenseData{ExpenseType: expenseType, Amount: amount, ReportingAmount: amount} + return ExpenseData{ExpenseType: expenseType, ReportingAmount: amount} } // currentMonthNow is inside the compute period's month (May 2026) so provisional diff --git a/services/finance/internal/service/healthscore_trend.go b/services/finance/internal/service/healthscore_trend.go index 27c973ff..dcdd3f44 100644 --- a/services/finance/internal/service/healthscore_trend.go +++ b/services/finance/internal/service/healthscore_trend.go @@ -69,6 +69,7 @@ func (s *FinanceService) GetHealthScoreTrend(ctx context.Context, userID string, if !isProvisional(period.Year, period.Month, now) { if scalar := scalarByMonth[[2]int32{period.Year, period.Month}]; scalar != nil && scalar.FormulaVersion == model.FormulaVersion { points[i] = *scalar + points[i].ReportingCurrency = period.ReportingCurrency continue } } @@ -78,12 +79,13 @@ func (s *FinanceService) GetHealthScoreTrend(ctx context.Context, userID string, return nil, err } points[i] = model.HealthScoreTrendPoint{ - Year: score.Year, - Month: score.Month, - Total: score.Total, - Band: score.Band, - Provisional: score.Provisional, - FormulaVersion: score.FormulaVersion, + Year: score.Year, + Month: score.Month, + Total: score.Total, + Band: score.Band, + Provisional: score.Provisional, + FormulaVersion: score.FormulaVersion, + ReportingCurrency: period.ReportingCurrency, } } return points, nil diff --git a/services/finance/internal/service/healthscore_trend_test.go b/services/finance/internal/service/healthscore_trend_test.go index 4000297b..de3f0815 100644 --- a/services/finance/internal/service/healthscore_trend_test.go +++ b/services/finance/internal/service/healthscore_trend_test.go @@ -69,7 +69,6 @@ func TestGetHealthScoreTrend_ProvisionalLast(t *testing.T) { repo.On("ListHealthScoreScalars", mock.Anything, "user-1").Return([]*model.HealthScoreTrendPoint{ scalarPoint(2026, 5, 65), scalarPoint(2026, 4, 60), }, nil) - repo.On("GetDefaults", mock.Anything, "user-1").Return(&model.DefaultSettings{Currency: "USD"}, nil) for month := int32(4); month <= 6; month++ { expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), month). Return([]ExpenseData{healthExpense("desires", 80000)}, nil) @@ -126,7 +125,6 @@ func TestGetHealthScoreTrend_StaleScalarRecomputes(t *testing.T) { repo.On("ListPeriods", mock.Anything, "user-1").Return([]*model.BudgetPeriod{healthPeriodMonth(2026, 5)}, nil) repo.On("ListHealthScoreScalars", mock.Anything, "user-1").Return([]*model.HealthScoreTrendPoint{staleMay}, nil) repo.On("GetHealthScore", mock.Anything, "user-1", int32(2026), int32(5)).Return(nil, nil) - repo.On("GetDefaults", mock.Anything, "user-1").Return(&model.DefaultSettings{Currency: "USD"}, nil) expClient.On("GetExpensesForPeriod", mock.Anything, "user-1", int32(2026), int32(5)). Return([]ExpenseData{healthExpense("desires", 80000)}, nil) repo.On("UpsertHealthScore", mock.Anything, "user-1", mock.Anything).Return(nil, nil) @@ -139,6 +137,33 @@ func TestGetHealthScoreTrend_StaleScalarRecomputes(t *testing.T) { repo.AssertCalled(t, "UpsertHealthScore", mock.Anything, "user-1", mock.Anything) } +// --- Health-score trend reporting currency --- + +func TestGetHealthScoreTrend_ReportingCurrencyPerPoint(t *testing.T) { + repo := new(mockRepo) + txBeg := new(mockTxBeg) + expClient := new(mockExpClient) + svc := newTagTestServiceNow(repo, txBeg, expClient, func() time.Time { return nowDecember }) + + // Two stored months with different reporting currencies. + usdPeriod := healthPeriodMonth(2026, 5) + jpyPeriod := healthPeriodMonth(2026, 4) + jpyPeriod.ReportingCurrency = "JPY" + repo.On("ListPeriods", mock.Anything, "user-1").Return([]*model.BudgetPeriod{ + usdPeriod, jpyPeriod, + }, nil) + repo.On("ListHealthScoreScalars", mock.Anything, "user-1").Return([]*model.HealthScoreTrendPoint{ + scalarPoint(2026, 5, 70), scalarPoint(2026, 4, 65), + }, nil) + + points, err := svc.GetHealthScoreTrend(t.Context(), "user-1", 2026, 6, 6) + + require.NoError(t, err) + require.Len(t, points, 2) + assert.Equal(t, "JPY", points[0].ReportingCurrency, "first point gets period reporting currency") + assert.Equal(t, "USD", points[1].ReportingCurrency, "second point gets period reporting currency") +} + func TestGetHealthScoreTrend_ClampsMonths(t *testing.T) { repo := new(mockRepo) txBeg := new(mockTxBeg) diff --git a/services/finance/internal/service/historical_comparison_test.go b/services/finance/internal/service/historical_comparison_test.go index a1975672..f9e3e4f7 100644 --- a/services/finance/internal/service/historical_comparison_test.go +++ b/services/finance/internal/service/historical_comparison_test.go @@ -12,7 +12,50 @@ import ( "github.com/ItsThompson/gofin/services/finance/internal/model" ) -// --- GetHistoricalComparison fan-out regression tests --- +// --- Cross-currency historical comparison --- + +// TestGetHistoricalComparison_MixedCurrencyNotComparable asserts that when the +// current and previous periods have different reporting currencies, the +// Comparable flag is false, ChangePercent is zeroed, and the rolling average is +// suppressed. +func TestGetHistoricalComparison_MixedCurrencyNotComparable(t *testing.T) { + exp := newCountingExpenseClient() + currentPeriod := makePeriod("p12", 2026, 12) + currentPeriod.ReportingCurrency = "USD" + prevPeriod := makePeriod("p11", 2026, 11) + prevPeriod.ReportingCurrency = "EUR" + periods := []*model.BudgetPeriod{currentPeriod, prevPeriod} + exp.set(2026, 12, []ExpenseData{{ReportingAmount: 80000}}) + exp.set(2026, 11, []ExpenseData{{ReportingAmount: 70000}}) + svc := newFanoutService(historicalRepo(periods), exp) + + result, err := svc.GetHistoricalComparison(context.Background(), "user-1", 2026, 12) + require.NoError(t, err) + assert.Equal(t, int64(80000), result.CurrentSpent) + assert.Equal(t, int64(70000), result.PreviousSpent) + assert.Equal(t, "EUR", result.PreviousReportingCurrency) + assert.False(t, result.Comparable, "different reporting currencies are not comparable") + assert.Equal(t, float64(0), result.ChangePercent, "change percent zeroed when not comparable") + assert.Nil(t, result.RollingAverage, "rolling average suppressed for mixed currencies") +} + +// TestGetHistoricalComparison_SameCurrencyComparable asserts that same-currency +// adjacent periods produce a Comparable=true result with a valid change percent. +func TestGetHistoricalComparison_SameCurrencyComparable(t *testing.T) { + exp := newCountingExpenseClient() + periods := []*model.BudgetPeriod{ + makePeriod("p12", 2026, 12), + makePeriod("p11", 2026, 11), + } + exp.set(2026, 12, []ExpenseData{{ReportingAmount: 80000}}) + exp.set(2026, 11, []ExpenseData{{ReportingAmount: 70000}}) + svc := newFanoutService(historicalRepo(periods), exp) + + result, err := svc.GetHistoricalComparison(context.Background(), "user-1", 2026, 12) + require.NoError(t, err) + assert.True(t, result.Comparable) + assert.InDelta(t, 14.29, result.ChangePercent, 0.01) +} // TestGetHistoricalComparison_FanOutByteIdentical asserts the fan-out yields the // expected values (current/previous/change/rolling) while reading each distinct @@ -27,11 +70,11 @@ func TestGetHistoricalComparison_FanOutByteIdentical(t *testing.T) { makePeriod("p09", 2026, 9), makePeriod("p08", 2026, 8), } - exp.set(2026, 12, []ExpenseData{{Amount: 80000, ReportingAmount: 80000}}) - exp.set(2026, 11, []ExpenseData{{Amount: 70000, ReportingAmount: 70000}}) - exp.set(2026, 10, []ExpenseData{{Amount: 60000, ReportingAmount: 60000}}) - exp.set(2026, 9, []ExpenseData{{Amount: 50000, ReportingAmount: 50000}}) - exp.set(2026, 8, []ExpenseData{{Amount: 40000, ReportingAmount: 40000}}) + exp.set(2026, 12, []ExpenseData{{ReportingAmount: 80000}}) + exp.set(2026, 11, []ExpenseData{{ReportingAmount: 70000}}) + exp.set(2026, 10, []ExpenseData{{ReportingAmount: 60000}}) + exp.set(2026, 9, []ExpenseData{{ReportingAmount: 50000}}) + exp.set(2026, 8, []ExpenseData{{ReportingAmount: 40000}}) svc := newFanoutService(historicalRepo(periods), exp) result, err := svc.GetHistoricalComparison(context.Background(), "user-1", 2026, 12) @@ -60,7 +103,7 @@ func TestGetHistoricalComparison_FanOutRunsConcurrently(t *testing.T) { makePeriod("p10", 2026, 10), makePeriod("p09", 2026, 9), } for m := int32(9); m <= 12; m++ { - exp.set(2026, m, []ExpenseData{{Amount: int64(m) * 1000, ReportingAmount: int64(m) * 1000}}) + exp.set(2026, m, []ExpenseData{{ReportingAmount: int64(m) * 1000}}) } svc := newFanoutService(historicalRepo(periods), exp) @@ -79,7 +122,7 @@ func TestGetHistoricalComparison_FanOutWrapsError(t *testing.T) { makePeriod("p10", 2026, 10), makePeriod("p09", 2026, 9), } for m := int32(9); m <= 12; m++ { - exp.set(2026, m, []ExpenseData{{Amount: int64(m) * 1000, ReportingAmount: int64(m) * 1000}}) + exp.set(2026, m, []ExpenseData{{ReportingAmount: int64(m) * 1000}}) } exp.failOn(2026, 10, errors.New("upstream down")) svc := newFanoutService(historicalRepo(periods), exp) diff --git a/services/finance/internal/service/spending_trends_test.go b/services/finance/internal/service/spending_trends_test.go index 1632f071..245912d5 100644 --- a/services/finance/internal/service/spending_trends_test.go +++ b/services/finance/internal/service/spending_trends_test.go @@ -30,9 +30,9 @@ func TestComputeSpendingTrends_NormalSixMonths(t *testing.T) { SavingsPercent: 20, } expensesByMonth[i] = []ExpenseData{ - {Amount: 100000, ReportingAmount: 100000, ExpenseType: "essentials"}, - {Amount: 50000, ReportingAmount: 50000, ExpenseType: "desires"}, - {Amount: 20000, ReportingAmount: 20000, ExpenseType: "savings"}, + {ReportingAmount: 100000, ExpenseType: "essentials"}, + {ReportingAmount: 50000, ExpenseType: "desires"}, + {ReportingAmount: 20000, ExpenseType: "savings"}, } } @@ -80,7 +80,7 @@ func TestComputeSpendingTrends_TwelveMonthsWithGaps(t *testing.T) { SavingsPercent: 20, } expensesByMonth[idx] = []ExpenseData{ - {Amount: 80000, ReportingAmount: 80000, ExpenseType: "essentials"}, + {ReportingAmount: 80000, ExpenseType: "essentials"}, } } @@ -122,7 +122,7 @@ func TestComputeSpendingTrends_ZeroBudgetAmount(t *testing.T) { {BudgetAmount: 0, EssentialsPercent: 50, DesiresPercent: 30, SavingsPercent: 20}, } expensesByMonth := [][]ExpenseData{ - {{Amount: 5000, ReportingAmount: 5000, ExpenseType: "essentials"}}, + {{ReportingAmount: 5000, ExpenseType: "essentials"}}, } years := []int32{2026} monthSlice := []int32{2} @@ -142,9 +142,9 @@ func TestComputeSpendingTrends_SingleMonth(t *testing.T) { } expensesByMonth := [][]ExpenseData{ { - {Amount: 40000, ReportingAmount: 40000, ExpenseType: "essentials"}, - {Amount: 30000, ReportingAmount: 30000, ExpenseType: "desires"}, - {Amount: 10000, ReportingAmount: 10000, ExpenseType: "savings"}, + {ReportingAmount: 40000, ExpenseType: "essentials"}, + {ReportingAmount: 30000, ExpenseType: "desires"}, + {ReportingAmount: 10000, ExpenseType: "savings"}, }, } years := []int32{2026} @@ -164,6 +164,29 @@ func TestComputeSpendingTrends_SingleMonth(t *testing.T) { assert.Equal(t, float64(15), result[0].SavingsPercent) } +// --- Trend reporting currency --- + +func TestComputeSpendingTrends_ReportingCurrencyPerPoint(t *testing.T) { + periods := []*model.BudgetPeriod{ + {BudgetAmount: 300000, EssentialsPercent: 50, DesiresPercent: 30, SavingsPercent: 20, ReportingCurrency: "USD"}, + {BudgetAmount: 30000, EssentialsPercent: 50, DesiresPercent: 30, SavingsPercent: 20, ReportingCurrency: "JPY"}, + nil, // gap month + } + expensesByMonth := [][]ExpenseData{ + {{ExpenseType: "essentials"}}, + {{ExpenseType: "essentials"}}, + nil, + } + years := []int32{2026, 2026, 2026} + monthSlice := []int32{1, 2, 3} + + result := ComputeSpendingTrends(periods, expensesByMonth, years, monthSlice) + + assert.Equal(t, "USD", result[0].ReportingCurrency) + assert.Equal(t, "JPY", result[1].ReportingCurrency) + assert.Equal(t, "", result[2].ReportingCurrency, "nil period yields empty reporting currency") +} + func TestComputeSpendingTrends_NilPeriodReturnsZeros(t *testing.T) { // When a period is nil (no budget created that month), all fields should be zero periods := []*model.BudgetPeriod{nil} @@ -213,8 +236,8 @@ func TestGetSpendingTrends_FanOutByteIdentical(t *testing.T) { SavingsPercent: 20, }) exp.set(2026, m, []ExpenseData{ - {Amount: int64(m) * 1000, ReportingAmount: int64(m) * 1000, ExpenseType: "essentials"}, - {Amount: int64(m) * 500, ReportingAmount: int64(m) * 500, ExpenseType: "desires"}, + {ReportingAmount: int64(m) * 1000, ExpenseType: "essentials"}, + {ReportingAmount: int64(m) * 500, ExpenseType: "desires"}, }) } svc := newFanoutService(&fakeFanoutRepo{periods: periods}, exp) diff --git a/services/finance/internal/service/testdata/healthscore/snapshots.lock b/services/finance/internal/service/testdata/healthscore/snapshots.lock index fc28696f..baa38a4d 100644 --- a/services/finance/internal/service/testdata/healthscore/snapshots.lock +++ b/services/finance/internal/service/testdata/healthscore/snapshots.lock @@ -1,4 +1,5 @@ { "1": "e37d2cbb22a60eede87ecc5250a0811d742f60b9bc882aea3081dd27f899d0d3", - "2": "9b318a7eeb53b1580d3aeb60f7d0f2b1bbcb9618c134591212f8a774b801241c" + "2": "9b318a7eeb53b1580d3aeb60f7d0f2b1bbcb9618c134591212f8a774b801241c", + "3": "c5131810e8d39829a6984fd5f766e8451a401666beb6eea71b60ee82e5b474a1" } diff --git a/services/finance/internal/service/testdata/healthscore/v3.json b/services/finance/internal/service/testdata/healthscore/v3.json new file mode 100644 index 00000000..d47725ba --- /dev/null +++ b/services/finance/internal/service/testdata/healthscore/v3.json @@ -0,0 +1,40 @@ +{ + "year": 2026, + "month": 3, + "total": 77, + "band": "amber", + "provisional": false, + "formulaVersion": 3, + "reportingCurrency": "USD", + "components": [ + { + "key": "savings_achievement", + "score": 13, + "max": 25, + "detail": "Saved $300 of $600 target" + }, + { + "key": "budget_adherence", + "score": 25, + "max": 25, + "detail": "Spent $2,350 of $2,400 plan" + }, + { + "key": "allocation_balance", + "score": 25, + "max": 30, + "detail": "Desires 6 pts over target share" + }, + { + "key": "spending_stability", + "score": 14, + "max": 20, + "detail": "Desires spend varied ~29% month to month" + } + ], + "insight": { + "summary": "Savings is the softest score this month.", + "driver": "savings_achievement", + "nudge": "Move an extra $300 to savings to reach your target and lift your score about 12 points." + } +} diff --git a/services/fx/Dockerfile b/services/fx/Dockerfile index f651b72b..614cffb0 100644 --- a/services/fx/Dockerfile +++ b/services/fx/Dockerfile @@ -6,7 +6,7 @@ WORKDIR /app COPY fx/go.mod fx/go.sum* ./fx/ COPY apierr/go.mod ./apierr/ COPY dbmigrate/go.mod dbmigrate/go.sum* ./dbmigrate/ -COPY errkit/go.mod ./errkit/ +COPY errkit/go.mod errkit/go.sum* ./errkit/ COPY healthcheck/go.mod ./healthcheck/ COPY metrics/go.mod metrics/go.sum* ./metrics/ COPY shared/currency/go.mod ./shared/currency/