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 new file mode 100644 index 0000000..63d7669 --- /dev/null +++ b/frontend/apps/finance/src/features/expense-autocomplete/__tests__/suggestionAutofill.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import type { Tag } from "@/types"; +import { createExpenseSuggestionPatch } from "../suggestionAutofill"; +import type { ExpenseSuggestion } from "../types"; + +const tags: Tag[] = [ + { + id: "tag-food", + name: "Food", + isDefault: true, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, +]; + +const suggestion: ExpenseSuggestion = { + name: "Groceries", + amount: 1299, + currency: "USD", + expenseType: "essentials", + tagId: "tag-food", + frequency: 7, + lastUsedAt: "2026-05-28T19:02:11Z", + recencyBucket: "last_7_days", + frecencyScore: 42, +}; + +describe("createExpenseSuggestionPatch", () => { + it("formats suggestion fields for expense forms", () => { + expect(createExpenseSuggestionPatch(suggestion, tags)).toEqual({ + name: "Groceries", + amountDollars: "12.99", + expenseType: "essentials", + tagId: "tag-food", + }); + }); + + it("returns a null tagId when the suggestion tag is stale", () => { + expect( + createExpenseSuggestionPatch( + { ...suggestion, tagId: "deleted-tag" }, + tags, + ), + ).toEqual({ + name: "Groceries", + amountDollars: "12.99", + expenseType: "essentials", + tagId: null, + }); + }); +}); diff --git a/frontend/apps/finance/src/features/new-expense/__tests__/useExpenseAutocomplete.test.ts b/frontend/apps/finance/src/features/expense-autocomplete/__tests__/useExpenseAutocomplete.test.ts similarity index 100% rename from frontend/apps/finance/src/features/new-expense/__tests__/useExpenseAutocomplete.test.ts rename to frontend/apps/finance/src/features/expense-autocomplete/__tests__/useExpenseAutocomplete.test.ts diff --git a/frontend/apps/finance/src/features/new-expense/api.ts b/frontend/apps/finance/src/features/expense-autocomplete/api.ts similarity index 100% rename from frontend/apps/finance/src/features/new-expense/api.ts rename to frontend/apps/finance/src/features/expense-autocomplete/api.ts diff --git a/frontend/apps/finance/src/features/new-expense/components/ExpenseNameCombobox.tsx b/frontend/apps/finance/src/features/expense-autocomplete/components/ExpenseNameCombobox.tsx similarity index 94% rename from frontend/apps/finance/src/features/new-expense/components/ExpenseNameCombobox.tsx rename to frontend/apps/finance/src/features/expense-autocomplete/components/ExpenseNameCombobox.tsx index 83c4441..ee56d3b 100644 --- a/frontend/apps/finance/src/features/new-expense/components/ExpenseNameCombobox.tsx +++ b/frontend/apps/finance/src/features/expense-autocomplete/components/ExpenseNameCombobox.tsx @@ -14,17 +14,21 @@ import { useExpenseAutocomplete } from "../hooks/useExpenseAutocomplete"; import type { ExpenseSuggestion } from "../types"; export interface ExpenseNameComboboxProps { + id?: string; value: string; onValueChange: (value: string) => void; onSelectSuggestion: (suggestion: ExpenseSuggestion) => void; error?: string; + placeholder?: string; } export function ExpenseNameCombobox({ + id = "expense-name", value, onValueChange, onSelectSuggestion, error, + placeholder = "e.g. Grocery shopping", }: ExpenseNameComboboxProps) { const { state, actions } = useExpenseAutocomplete(); const { loadMore, setQuery } = actions; @@ -51,10 +55,10 @@ export function ExpenseNameCombobox({ return ( handleValueChange(event.target.value)} aria-invalid={!!error} diff --git a/frontend/apps/finance/src/features/new-expense/hooks/useExpenseAutocomplete.ts b/frontend/apps/finance/src/features/expense-autocomplete/hooks/useExpenseAutocomplete.ts similarity index 100% rename from frontend/apps/finance/src/features/new-expense/hooks/useExpenseAutocomplete.ts rename to frontend/apps/finance/src/features/expense-autocomplete/hooks/useExpenseAutocomplete.ts diff --git a/frontend/apps/finance/src/features/expense-autocomplete/index.ts b/frontend/apps/finance/src/features/expense-autocomplete/index.ts new file mode 100644 index 0000000..bfeec65 --- /dev/null +++ b/frontend/apps/finance/src/features/expense-autocomplete/index.ts @@ -0,0 +1,10 @@ +export { ExpenseNameCombobox } from "./components/ExpenseNameCombobox"; +export { useExpenseAutocomplete } from "./hooks/useExpenseAutocomplete"; +export { createExpenseSuggestionPatch } from "./suggestionAutofill"; +export type { + ExpenseAutocompleteActions, + ExpenseAutocompleteState, + ExpenseSuggestion, + ExpenseSuggestionPatch, + ExpenseSuggestionsResponse, +} from "./types"; diff --git a/frontend/apps/finance/src/features/expense-autocomplete/suggestionAutofill.ts b/frontend/apps/finance/src/features/expense-autocomplete/suggestionAutofill.ts new file mode 100644 index 0000000..43558c2 --- /dev/null +++ b/frontend/apps/finance/src/features/expense-autocomplete/suggestionAutofill.ts @@ -0,0 +1,20 @@ +import type { Tag } from "../../types"; +import type { ExpenseSuggestion, ExpenseSuggestionPatch } from "./types"; + +function formatMinorUnits(amount: number): string { + return (amount / 100).toFixed(2); +} + +export function createExpenseSuggestionPatch( + suggestion: ExpenseSuggestion, + tags: Tag[], +): ExpenseSuggestionPatch { + const hasValidTag = tags.some((tag) => tag.id === suggestion.tagId); + + return { + name: suggestion.name, + amountDollars: formatMinorUnits(suggestion.amount), + expenseType: suggestion.expenseType, + tagId: hasValidTag ? suggestion.tagId : null, + }; +} diff --git a/frontend/apps/finance/src/features/new-expense/types.ts b/frontend/apps/finance/src/features/expense-autocomplete/types.ts similarity index 86% rename from frontend/apps/finance/src/features/new-expense/types.ts rename to frontend/apps/finance/src/features/expense-autocomplete/types.ts index 5de0c85..d9b99bc 100644 --- a/frontend/apps/finance/src/features/new-expense/types.ts +++ b/frontend/apps/finance/src/features/expense-autocomplete/types.ts @@ -1,5 +1,12 @@ import type { ExpenseType } from "@gofin/core"; +export interface ExpenseSuggestionPatch { + name: string; + amountDollars: string; + expenseType: ExpenseType; + tagId: string | null; +} + export interface ExpenseSuggestion { name: string; amount: number; diff --git a/frontend/apps/finance/src/features/expense-detail/ExpenseDetailModal.tsx b/frontend/apps/finance/src/features/expense-detail/ExpenseDetailModal.tsx index b8d2ea3..1a24737 100644 --- a/frontend/apps/finance/src/features/expense-detail/ExpenseDetailModal.tsx +++ b/frontend/apps/finance/src/features/expense-detail/ExpenseDetailModal.tsx @@ -121,7 +121,7 @@ function CorrectionFormContainer({ submitting: boolean; submitError: string | null; }) { - const { state, actions } = useCorrectionForm(expense, onSubmit); + const { state, actions } = useCorrectionForm(expense, onSubmit, tags); return ( ); } 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 4672ecb..d018172 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 @@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router"; import { ExpenseDetailModal } from "@/features/expense-detail"; import type { Expense, Tag } from "@/types"; +import type { ExpenseSuggestionsResponse } from "../../expense-autocomplete"; const mockFetch = vi.fn(); global.fetch = mockFetch; @@ -92,6 +93,36 @@ function mockExpenseAndHistory( }); } +function mockExpenseSuggestions( + overrides: Partial = {}, +) { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + data: [ + { + name: "Train Pass", + amount: 1234, + currency: "USD", + expenseType: "desires", + tagId: "tag-transport", + frequency: 4, + lastUsedAt: "2026-05-28T19:02:11Z", + recencyBucket: "last_7_days", + frecencyScore: 22, + }, + ], + total: 1, + page: 1, + pageSize: 50, + hasMore: false, + ...overrides, + }), + }); +} + function mockCorrectionSuccess() { mockFetch.mockResolvedValueOnce({ ok: true, @@ -284,6 +315,8 @@ describe("ExpenseDetailModal", () => { ).toBeInTheDocument(); }); + mockExpenseSuggestions(); + await user.click(screen.getByText("Correct This Expense")); expect(screen.getByText("Correct Expense")).toBeInTheDocument(); @@ -301,6 +334,8 @@ describe("ExpenseDetailModal", () => { ).toBeInTheDocument(); }); + mockExpenseSuggestions(); + await user.click(screen.getByText("Correct This Expense")); const nameInput = screen.getByLabelText("Name") as HTMLInputElement; @@ -312,6 +347,44 @@ describe("ExpenseDetailModal", () => { expect(dateInput.value).toBe("2026-05-02"); }); + it("autofills correction fields from an explicit expense suggestion selection", async () => { + mockExpenseAndHistory(activeExpense); + const user = userEvent.setup(); + renderModal(); + + await waitFor(() => { + expect( + screen.getByText("Correct This Expense"), + ).toBeInTheDocument(); + }); + + mockExpenseSuggestions(); + + await user.click(screen.getByText("Correct This Expense")); + + const nameInput = screen.getByLabelText("Name") as HTMLInputElement; + await user.clear(nameInput); + await user.type(nameInput, "tra"); + + await waitFor(() => { + expect(screen.getByText("Train Pass")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("Train Pass")); + + expect(nameInput.value).toBe("Train Pass"); + expect((screen.getByLabelText("Amount") as HTMLInputElement).value).toBe( + "12.34", + ); + expect(screen.getByLabelText("desires")).toBeChecked(); + expect((screen.getByLabelText("Tag") as HTMLSelectElement).value).toBe( + "tag-transport", + ); + expect((screen.getByLabelText("Date") as HTMLInputElement).value).toBe( + "2026-05-02", + ); + }); + it("submits correction and calls onCorrected", async () => { mockExpenseAndHistory(activeExpense); const user = userEvent.setup(); @@ -323,6 +396,8 @@ describe("ExpenseDetailModal", () => { ).toBeInTheDocument(); }); + mockExpenseSuggestions(); + await user.click(screen.getByText("Correct This Expense")); // Modify the name @@ -353,6 +428,8 @@ describe("ExpenseDetailModal", () => { ).toBeInTheDocument(); }); + mockExpenseSuggestions(); + await user.click(screen.getByText("Correct This Expense")); expect(screen.getByText("Correct Expense")).toBeInTheDocument(); @@ -371,6 +448,8 @@ describe("ExpenseDetailModal", () => { ).toBeInTheDocument(); }); + mockExpenseSuggestions(); + await user.click(screen.getByText("Correct This Expense")); // Clear the name field @@ -393,6 +472,8 @@ describe("ExpenseDetailModal", () => { ).toBeInTheDocument(); }); + mockExpenseSuggestions(); + await user.click(screen.getByText("Correct This Expense")); // Mock a 409 response @@ -426,6 +507,8 @@ describe("ExpenseDetailModal", () => { ).toBeInTheDocument(); }); + mockExpenseSuggestions(); + await user.click(screen.getByText("Correct This Expense")); // Mock a 403 response 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 9ed736d..a6dfcab 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 @@ -1,7 +1,37 @@ import { describe, it, expect, vi } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { useCorrectionForm } from "../hooks/useCorrectionForm"; -import type { Expense } from "@/types"; +import type { Expense, Tag } from "@/types"; +import type { ExpenseSuggestion } from "../../expense-autocomplete"; + +const mockTags: Tag[] = [ + { + id: "tag-food", + name: "Food", + isDefault: true, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, + { + id: "tag-transport", + name: "Transport", + isDefault: true, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, +]; + +const mockSuggestion: ExpenseSuggestion = { + name: "Train Pass", + amount: 1234, + currency: "USD", + expenseType: "desires", + tagId: "tag-transport", + frequency: 4, + lastUsedAt: "2026-05-28T19:02:11Z", + recencyBucket: "last_7_days", + frecencyScore: 22, +}; const mockExpense: Expense = { id: "exp-1", @@ -127,6 +157,46 @@ describe("useCorrectionForm", () => { }); }); + describe("applySuggestion", () => { + it("updates name, amount, type, and valid tag from a selected suggestion", () => { + const onSubmit = vi.fn(); + const { result } = renderHook(() => + useCorrectionForm(mockExpense, onSubmit, mockTags), + ); + + act(() => { + result.current.actions.applySuggestion(mockSuggestion); + }); + + expect(result.current.state.fields).toEqual({ + name: "Train Pass", + amountDollars: "12.34", + expenseType: "desires", + tagId: "tag-transport", + expenseDate: "2026-05-02", + }); + }); + + it("keeps the current tag when the selected suggestion tag is stale", () => { + const onSubmit = vi.fn(); + const { result } = renderHook(() => + useCorrectionForm(mockExpense, onSubmit, mockTags), + ); + + act(() => { + result.current.actions.applySuggestion({ + ...mockSuggestion, + tagId: "deleted-tag", + }); + }); + + expect(result.current.state.fields.tagId).toBe("tag-food"); + expect(result.current.state.fields.name).toBe("Train Pass"); + expect(result.current.state.fields.amountDollars).toBe("12.34"); + expect(result.current.state.fields.expenseType).toBe("desires"); + }); + }); + describe("handleSubmit", () => { it("prevents default form event", () => { const onSubmit = vi.fn(); diff --git a/frontend/apps/finance/src/features/expense-detail/components/CorrectionForm.tsx b/frontend/apps/finance/src/features/expense-detail/components/CorrectionForm.tsx index b01dd4e..71eb008 100644 --- a/frontend/apps/finance/src/features/expense-detail/components/CorrectionForm.tsx +++ b/frontend/apps/finance/src/features/expense-detail/components/CorrectionForm.tsx @@ -10,6 +10,10 @@ import { FormLabel, FormMessage, } from "@gofin/ui/components/form"; +import { + ExpenseNameCombobox, + type ExpenseSuggestion, +} from "../../expense-autocomplete"; interface CorrectionFormProps { currency: string; @@ -21,6 +25,7 @@ interface CorrectionFormProps { onCancel: () => void; onSubmit: (event: FormEvent) => void; onFieldChange: (key: keyof ExpenseFields, value: string) => void; + onSelectSuggestion: (suggestion: ExpenseSuggestion) => void; } export function CorrectionForm({ @@ -33,6 +38,7 @@ export function CorrectionForm({ onCancel, onSubmit, onFieldChange, + onSelectSuggestion, }: CorrectionFormProps) { const currencySymbol = getCurrencySymbol(currency); @@ -41,14 +47,13 @@ export function CorrectionForm({ {/* Name */} Name - onFieldChange("name", event.target.value)} - aria-invalid={!!fieldErrors.name} + onValueChange={(value) => onFieldChange("name", value)} + onSelectSuggestion={onSelectSuggestion} + error={fieldErrors.name} /> - {fieldErrors.name} {/* Amount */} 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 20e3766..7a99689 100644 --- a/frontend/apps/finance/src/features/expense-detail/hooks/useCorrectionForm.ts +++ b/frontend/apps/finance/src/features/expense-detail/hooks/useCorrectionForm.ts @@ -1,6 +1,10 @@ import { useCallback, type FormEvent } from "react"; import type { ExpenseFields } from "../../../lib/validate-expense-fields"; -import type { Expense, CorrectExpenseRequest } from "../../../types"; +import type { Expense, CorrectExpenseRequest, Tag } from "../../../types"; +import { + createExpenseSuggestionPatch, + type ExpenseSuggestion, +} from "../../expense-autocomplete"; import { useExpenseFields } from "../../new-expense/hooks/useExpenseFields"; /** State returned by useCorrectionForm (field-level only). */ @@ -17,6 +21,8 @@ export interface CorrectionFormActions { setField: (key: keyof ExpenseFields, value: string) => void; /** Clear a field error. */ clearFieldError: (field: string) => void; + /** Apply fields from an explicitly selected historical suggestion. */ + applySuggestion: (suggestion: ExpenseSuggestion) => void; /** Submit the correction (validates then calls onSubmit). */ handleSubmit: (event: FormEvent) => void; } @@ -32,6 +38,7 @@ export interface CorrectionFormActions { export function useCorrectionForm( expense: Expense, onSubmit: (form: CorrectExpenseRequest) => void, + tags: Tag[] = [], ): { state: CorrectionFormState; actions: CorrectionFormActions } { const expenseFields = useExpenseFields({ name: expense.name, @@ -41,6 +48,21 @@ export function useCorrectionForm( expenseDate: expense.expenseDate, }); + const applySuggestion = useCallback( + (suggestion: ExpenseSuggestion) => { + const patch = createExpenseSuggestionPatch(suggestion, tags); + + expenseFields.setField("name", patch.name); + expenseFields.setField("amountDollars", patch.amountDollars); + expenseFields.setField("expenseType", patch.expenseType); + + if (patch.tagId) { + expenseFields.setField("tagId", patch.tagId); + } + }, + [expenseFields, tags], + ); + const handleSubmit = useCallback( (event: FormEvent) => { event.preventDefault(); @@ -73,6 +95,7 @@ export function useCorrectionForm( actions: { setField: expenseFields.setField, clearFieldError: expenseFields.clearFieldError, + applySuggestion, handleSubmit, }, }; diff --git a/frontend/apps/finance/src/features/new-expense/NewExpenseFeature.tsx b/frontend/apps/finance/src/features/new-expense/NewExpenseFeature.tsx index 707ba03..1375863 100644 --- a/frontend/apps/finance/src/features/new-expense/NewExpenseFeature.tsx +++ b/frontend/apps/finance/src/features/new-expense/NewExpenseFeature.tsx @@ -16,7 +16,7 @@ import { } from "@gofin/ui/components/form"; import { PlusCircle } from "lucide-react"; import type { FinancePageProps } from "../../types/pages"; -import { ExpenseNameCombobox } from "./components/ExpenseNameCombobox"; +import { ExpenseNameCombobox } from "../expense-autocomplete"; import { useNewExpenseForm, EXPENSE_TYPES } from "./hooks/useNewExpenseForm"; /** 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 89b92ed..a1bd2cd 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 @@ -5,7 +5,7 @@ import { MemoryRouter } from "react-router"; import type { User } from "@gofin/core"; import { NewExpenseFeature } from "../index"; -import type { ExpenseSuggestionsResponse } from "../types"; +import type { ExpenseSuggestionsResponse } from "../../expense-autocomplete"; const mockFetch = vi.fn(); global.fetch = mockFetch; 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 446329c..bf9f7e7 100644 --- a/frontend/apps/finance/src/features/new-expense/hooks/useNewExpenseForm.ts +++ b/frontend/apps/finance/src/features/new-expense/hooks/useNewExpenseForm.ts @@ -11,13 +11,12 @@ import type { Tag, TagListResponse, } from "../../../types"; -import type { ExpenseSuggestion } from "../types"; +import { + createExpenseSuggestionPatch, + type ExpenseSuggestion, +} from "../../expense-autocomplete"; import { useExpenseFields } from "./useExpenseFields"; -function formatMinorUnits(amount: number): string { - return (amount / 100).toFixed(2); -} - export { EXPENSE_TYPES }; export type { ExpenseType, ExpenseFields }; @@ -96,15 +95,14 @@ export function useNewExpenseForm(currency: string): { } function applySuggestion(suggestion: ExpenseSuggestion) { - expenseFields.setField("name", suggestion.name); - expenseFields.setField( - "amountDollars", - formatMinorUnits(suggestion.amount), - ); - expenseFields.setField("expenseType", suggestion.expenseType); - - if (tags.some((tag) => tag.id === suggestion.tagId)) { - expenseFields.setField("tagId", suggestion.tagId); + const patch = createExpenseSuggestionPatch(suggestion, tags); + + expenseFields.setField("name", patch.name); + expenseFields.setField("amountDollars", patch.amountDollars); + expenseFields.setField("expenseType", patch.expenseType); + + if (patch.tagId) { + expenseFields.setField("tagId", patch.tagId); } } diff --git a/frontend/apps/finance/src/features/new-expense/index.ts b/frontend/apps/finance/src/features/new-expense/index.ts index 69f7fe4..14e63f3 100644 --- a/frontend/apps/finance/src/features/new-expense/index.ts +++ b/frontend/apps/finance/src/features/new-expense/index.ts @@ -1,7 +1 @@ export { NewExpenseFeature } from "./NewExpenseFeature"; -export type { - ExpenseAutocompleteActions, - ExpenseAutocompleteState, - ExpenseSuggestion, - ExpenseSuggestionsResponse, -} from "./types";