diff --git a/.gitignore b/.gitignore index da652ba..9943cd8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ node_modules +.idea + # Swap the comments on the following lines if you wish to use zero-installs # In that case, don't forget to run `yarn config set enableGlobalCache false`! # Documentation here: https://yarnpkg.com/features/caching#zero-installs @@ -14,4 +16,4 @@ node_modules #!.yarn/cache .pnp.* -coverage \ No newline at end of file +coverage diff --git a/README.md b/README.md index 1456b2e..d402504 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ [![codecov](https://codecov.io/gh/LucaDiba/monyfox/graph/badge.svg?token=1PDRWAPU6X)](https://codecov.io/gh/LucaDiba/monyfox) -Welcome to MonyFox, your comprehensive open-source solution for managing your finances efficiently. MonyFox is a web app that works 100% locally. It is designed to help you keep track of your money, visualize your financial data with insightful charts, and plan for a better financial future. +Welcome to MonyFox, your comprehensive open-source solution for managing your finances efficiently. MonyFox is a web app +that works 100% locally. It is designed to help you keep track of your money, visualize your financial data with +insightful charts, and plan for a better financial future. ![MonyFox dashboard screenshot](./images/dashboard.png) @@ -13,6 +15,7 @@ Welcome to MonyFox, your comprehensive open-source solution for managing your fi - 💻 **User-Friendly Interface:** Intuitive design for seamless navigation and usage. - 💶 **Multi-Currency Support:** Manage your finances in multiple currencies with ease. - 📈 **Stock Tracking:** Monitor your investments and track stock performance. +- 📂 **Data Import:** Import financial data from various sources for a unified view. - 💾 **Backup and Restore:** Easily backup and restore your financial data. - 🌐 **100% Open Source:** Fully open-source, ensuring transparency and customization options. - 🏠 **100% Local:** All data is stored locally on your device, ensuring privacy and security. @@ -21,9 +24,9 @@ Welcome to MonyFox, your comprehensive open-source solution for managing your fi - 💳 **Budgeting Tools:** Set budgets and track your spending to stay on target. - 📜 **Debt Management:** Track your debts and payoff plans. -- 📂 **Data Import:** Import financial data from various sources for a unified view. - 📱 **Mobile App:** Access MonyFox on the go with a dedicated PWA. -- 🌐 **Sync Across Devices:** Sync your financial data across multiple devices for seamless access. The data will be encrypted and stored in a secure cloud service. +- 🌐 **Sync Across Devices:** Sync your financial data across multiple devices for seamless access. The data will be + encrypted and stored in a secure cloud service. ## Getting Started diff --git a/apps/client/dashboard/.prettierrc b/apps/client/dashboard/.prettierrc new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/apps/client/dashboard/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/apps/client/dashboard/package.json b/apps/client/dashboard/package.json index 5099498..db2ee81 100644 --- a/apps/client/dashboard/package.json +++ b/apps/client/dashboard/package.json @@ -15,6 +15,7 @@ "@formkit/auto-animate": "^0.8.4", "@hookform/resolvers": "^5.2.1", "@js-joda/core": "^5.6.5", + "@monyfox/client-transactions-importer": "workspace:*", "@monyfox/common-data": "workspace:*", "@monyfox/common-symbol": "workspace:*", "@monyfox/common-symbol-exchange": "workspace:*", @@ -37,8 +38,10 @@ "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "framer-motion": "^12.23.12", "graph-data-structure": "^4.5.0", + "immer": "^10.1.3", "lucide-react": "^0.542.0", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -48,6 +51,7 @@ "tailwind-merge": "^3.3.1", "tw-animate-css": "^1.3.7", "ulid": "^3.0.1", + "use-immer": "^0.11.0", "zod": "^4.1.5" }, "devDependencies": { @@ -78,6 +82,7 @@ "jsdom": "^26.1.0", "msw": "^2.7.4", "postcss": "^8.5.6", + "prettier": "^3.6.2", "tailwindcss": "^4.1.12", "typescript": "^5.9.2", "vite": "^7.1.11", diff --git a/apps/client/dashboard/src/components/auth/profile-selection.tsx b/apps/client/dashboard/src/components/auth/profile-selection.tsx index 15e0011..c68ae4f 100644 --- a/apps/client/dashboard/src/components/auth/profile-selection.tsx +++ b/apps/client/dashboard/src/components/auth/profile-selection.tsx @@ -156,6 +156,8 @@ function CreateProfileModal({ assetSymbolExchangersMetadata: { alphavantage: null }, transactions: [], transactionCategories: [], + transactionsImporters: [], + importedTransactions: [], lastUpdated: new Date().toISOString(), }, }, diff --git a/apps/client/dashboard/src/components/charts/charts-page.test.tsx b/apps/client/dashboard/src/components/charts/charts-page.test.tsx index 417bae3..8be8f62 100644 --- a/apps/client/dashboard/src/components/charts/charts-page.test.tsx +++ b/apps/client/dashboard/src/components/charts/charts-page.test.tsx @@ -1,10 +1,7 @@ import { TestContextProvider } from "@/utils/tests/contexts"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { ChartsPage } from "./charts-page"; -import { - fireEvent, - render, -} from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; const originalLanguageDescriptor = Object.getOwnPropertyDescriptor( navigator, @@ -49,10 +46,9 @@ describe("ChartsPage", () => { expect(r.getByTestId("flow-chart")).toBeInTheDocument(); expect(r.queryByTestId("net-worth-chart")).not.toBeInTheDocument(); - fireEvent.click(r.getByText("Net worth")); + fireEvent.mouseDown(r.getByText("Net worth")); - // TODO: fix this test - // expect(r.queryByTestId("flow-chart")).not.toBeInTheDocument(); - // expect(r.getByTestId("net-worth-chart")).toBeInTheDocument(); + expect(r.queryByTestId("flow-chart")).not.toBeInTheDocument(); + expect(r.getByTestId("net-worth-chart")).toBeInTheDocument(); }); }); diff --git a/apps/client/dashboard/src/components/dashboard-page.tsx b/apps/client/dashboard/src/components/dashboard-page.tsx index 1e84371..8a3117f 100644 --- a/apps/client/dashboard/src/components/dashboard-page.tsx +++ b/apps/client/dashboard/src/components/dashboard-page.tsx @@ -1,13 +1,20 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { AccountsBalance } from "@/components/accounts-balance"; import { TransactionsTable } from "@/components/transaction/transactions-table"; -import { AddTransactionFloatingButton } from "@/components/transaction/transaction-form"; +import { AddTransactionButton } from "@/components/transaction/transaction-form"; import { useAssetSymbolExchangeRate } from "@/hooks/use-asset-symbol-exchange-rate"; import { Spinner } from "@/components/ui/spinner"; import { DestructiveAlert } from "@/components/ui/alert"; import { ChartExpenseByCategory } from "@/components/charts/chart-expense-by-category"; +import { Button } from "./ui/button"; +import { ImportIcon } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { useProfile } from "@/hooks/use-profile"; export function DashboardPage() { + const { + user: { id: profileId }, + } = useProfile(); const { isLoading, error } = useAssetSymbolExchangeRate(); return ( @@ -41,14 +48,31 @@ export function DashboardPage() {
- Transactions + +
Transactions
+
+ + + + +
+
- + ); } diff --git a/apps/client/dashboard/src/components/data-table.tsx b/apps/client/dashboard/src/components/data-table.tsx new file mode 100644 index 0000000..5c49a84 --- /dev/null +++ b/apps/client/dashboard/src/components/data-table.tsx @@ -0,0 +1,209 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useState } from "react"; +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + Table as ReactTable, + useReactTable, + VisibilityState, +} from "@tanstack/react-table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeftIcon, + ChevronsRightIcon, +} from "lucide-react"; +import { Label } from "./ui/label"; +import { Button } from "./ui/button"; +import { TableOptions } from "@tanstack/table-core"; + +export function DataTable({ + data, + columns, + getRowId, + options = {}, +}: { + data: Array; + columns: ColumnDef[]; + getRowId: (row: DataT) => string; + options?: Omit, "data" | "columns" | "getCoreRowModel">; +}) { + const [rowSelection, setRowSelection] = useState({}); + const [columnVisibility, setColumnVisibility] = useState({}); + const [columnFilters, setColumnFilters] = useState([]); + const [sorting, setSorting] = useState([]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + pagination, + }, + getRowId, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + ...options, + }); + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
+ +
+ ); +} + +function PaginationContainer({ table }: { table: ReactTable }) { + return ( +
+
+ {table.getFilteredRowModel().rows.length} rows total. +
+
+
+ + +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+ ); +} diff --git a/apps/client/dashboard/src/components/settings/backup/page.test.tsx b/apps/client/dashboard/src/components/settings/backup/page.test.tsx index 5f5b999..9c237e0 100644 --- a/apps/client/dashboard/src/components/settings/backup/page.test.tsx +++ b/apps/client/dashboard/src/components/settings/backup/page.test.tsx @@ -60,7 +60,7 @@ describe("SettingsBackupPage", () => { // @ts-expect-error - [0].text() exists - source: trust me bro (await createObjectURLMock.mock.lastCall[0].text()) as string; expect(generatedBlobText).toMatchInlineSnapshot( - `"{"id":"TEST_PROFILE_ID","user":"TEST_USER","data":{"encrypted":false,"data":{"accounts":[{"id":"ACCOUNT_1","name":"Account 1","isPersonalAsset":true},{"id":"ACCOUNT_2","name":"Account 2","isPersonalAsset":true}],"assetSymbols":[{"id":"EUR","code":"EUR","displayName":"EUR","type":"fiat"},{"id":"USD","code":"USD","displayName":"USD","type":"fiat"},{"id":"CHF","code":"CHF","displayName":"CHF","type":"fiat"},{"id":"MWRD","code":"MWRD","displayName":"MWRD ETF name","type":"stock"}],"assetSymbolExchanges":[],"assetSymbolExchangersMetadata":{"alphavantage":{"apiKey":"TEST_API_KEY"}},"transactions":[{"id":"TRANSACTION_1","description":"Income","transactionDate":"2024-01-01","accountingDate":"2024-01-01","transactionCategoryId":"CATEGORY_1","from":{"account":{"name":"Income"},"amount":950,"symbolId":"EUR"},"to":{"account":{"id":"ACCOUNT_1"},"amount":950,"symbolId":"EUR"}},{"id":"TRANSACTION_2","description":"Expense","transactionDate":"2024-01-01","accountingDate":"2024-01-01","transactionCategoryId":null,"from":{"account":{"id":"ACCOUNT_1"},"amount":23,"symbolId":"EUR"},"to":{"account":{"name":"Expense"},"amount":23,"symbolId":"EUR"}},{"id":"TRANSACTION_3","description":"Income USD","transactionDate":"2024-01-01","accountingDate":"2024-01-01","transactionCategoryId":"CATEGORY_1","from":{"account":{"name":"Income"},"amount":950,"symbolId":"USD"},"to":{"account":{"id":"ACCOUNT_1"},"amount":950,"symbolId":"USD"}}],"transactionCategories":[{"id":"CATEGORY_1","name":"Category 1","parentTransactionCategoryId":null},{"id":"CATEGORY_1_1","name":"Subcategory 1-1","parentTransactionCategoryId":"CATEGORY_1"}],"lastUpdated":"2024-01-01T00:00:00.000Z"}},"schemaVersion":"1"}"`, + `"{"id":"TEST_PROFILE_ID","user":"TEST_USER","data":{"encrypted":false,"data":{"accounts":[{"id":"ACCOUNT_1","name":"Account 1","isPersonalAsset":true},{"id":"ACCOUNT_2","name":"Account 2","isPersonalAsset":true}],"assetSymbols":[{"id":"EUR","code":"EUR","displayName":"EUR","type":"fiat"},{"id":"USD","code":"USD","displayName":"USD","type":"fiat"},{"id":"CHF","code":"CHF","displayName":"CHF","type":"fiat"},{"id":"MWRD","code":"MWRD","displayName":"MWRD ETF name","type":"stock"}],"assetSymbolExchanges":[],"assetSymbolExchangersMetadata":{"alphavantage":{"apiKey":"TEST_API_KEY"}},"transactions":[{"id":"TRANSACTION_1","description":"Income","transactionDate":"2024-01-01","accountingDate":"2024-01-01","transactionCategoryId":"CATEGORY_1","from":{"account":{"name":"Income"},"amount":950,"symbolId":"EUR"},"to":{"account":{"id":"ACCOUNT_1"},"amount":950,"symbolId":"EUR"}},{"id":"TRANSACTION_2","description":"Expense","transactionDate":"2024-01-01","accountingDate":"2024-01-01","transactionCategoryId":null,"from":{"account":{"id":"ACCOUNT_1"},"amount":23,"symbolId":"EUR"},"to":{"account":{"name":"Expense"},"amount":23,"symbolId":"EUR"}},{"id":"TRANSACTION_3","description":"Income USD","transactionDate":"2024-01-01","accountingDate":"2024-01-01","transactionCategoryId":"CATEGORY_1","from":{"account":{"name":"Income"},"amount":950,"symbolId":"USD"},"to":{"account":{"id":"ACCOUNT_1"},"amount":950,"symbolId":"USD"}}],"transactionCategories":[{"id":"CATEGORY_1","name":"Category 1","parentTransactionCategoryId":null},{"id":"CATEGORY_1_1","name":"Subcategory 1-1","parentTransactionCategoryId":"CATEGORY_1"}],"transactionsImporters":[{"id":"IMPORTER_1","name":"Importer 1","data":{"provider":"chase-card","defaultAccountId":"ACCOUNT_1","defaultSymbolId":"USD"}}],"importedTransactions":[],"lastUpdated":"2024-01-01T00:00:00.000Z"}},"schemaVersion":"1"}"`, ); }); }); diff --git a/apps/client/dashboard/src/components/transaction/import/import-dashboard-page.test.tsx b/apps/client/dashboard/src/components/transaction/import/import-dashboard-page.test.tsx new file mode 100644 index 0000000..51d2840 --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/import-dashboard-page.test.tsx @@ -0,0 +1,29 @@ +import { describe, test, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { TestContextProvider } from "@/utils/tests/contexts"; +import { ImportDashboardPage } from "./import-dashboard-page"; + +describe("ImportDashboardPage", () => { + test("renders help alert and sections", () => { + render( + + + , + ); + + // Help alert + expect( + screen.getByText( + /What are importers and why should I use them\?/i, + ), + ).toBeInTheDocument(); + + // User importers section + expect(screen.getByText("Your importers")).toBeInTheDocument(); + // Create new importer section + expect(screen.getByText("Create new importer")).toBeInTheDocument(); + + // The default TestContextProvider provides one importer + expect(screen.getByText("Importer 1")).toBeInTheDocument(); + }); +}); diff --git a/apps/client/dashboard/src/components/transaction/import/import-dashboard-page.tsx b/apps/client/dashboard/src/components/transaction/import/import-dashboard-page.tsx new file mode 100644 index 0000000..02cdb06 --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/import-dashboard-page.tsx @@ -0,0 +1,31 @@ +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { UserImportersCard } from "./importers/user-importers-card"; +import { CreateImporterCard } from "./importers/create-importer-card"; + +export function ImportDashboardPage() { + return ( +
+ + + +
+ ); +} + +function HelpAlert() { + return ( + + What are importers and why should I use them? + + Importers allow you to import transactions automatically instead of + adding each one manually. For example, you can import your debit/credit + card transactions from your bank, or your stock trades from your + brokerage. +
+ For each account, you have to create an importer. After that, you can + import transactions with just a few clicks. For example, you can create + an importer for your credit card and one for your debit card. +
+
+ ); +} diff --git a/apps/client/dashboard/src/components/transaction/import/import-transactions/import-transactions-page.test.tsx b/apps/client/dashboard/src/components/transaction/import/import-transactions/import-transactions-page.test.tsx new file mode 100644 index 0000000..4123b27 --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/import-transactions/import-transactions-page.test.tsx @@ -0,0 +1,105 @@ +import { describe, test, expect } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { TestContextProvider } from "@/utils/tests/contexts"; +import { ImportTransactionsPage } from "./import-transactions-page"; + +function createChaseCsvFile() { + const headers = [ + "Transaction Date", + "Post Date", + "Description", + "Category", + "Type", + "Amount", + "Memo", + ].join(","); + + const rows = [ + // Expense (negative amount -> Sale) + ["09/12/2025", "09/14/2025", "MERCHANT 1", "Shopping", "Sale", "-21.45", ""], + // Income (positive amount -> Return) + ["09/12/2025", "09/14/2025", "MERCHANT 2", "Food & Drink", "Return", "25.00", ""], + // Transfer (Payment) + ["08/01/2025", "08/03/2025", "Payment Thank You-Mobile", "", "Payment", "50.00", ""], + ] + .map((r) => r.join(",")) + .join("\n"); + + const content = `${headers}\n${rows}\n`; + return new File([content], "chase.csv", { type: "text/csv" }); +} + +describe("ImportTransactionsPage", () => { + test("shows alert when importer is not found", () => { + render( + + + , + ); + + expect(screen.getByText("Importer not found")).toBeInTheDocument(); + }); + + test("renders real Chase importer form when importer exists", () => { + render( + + + , + ); + + expect( + screen.getByText("Upload your Chase CSV file"), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /next/i })).toBeInTheDocument(); + }); + + test("uploads a CSV, shows imported transactions card with tabs, then can reset", async () => { + render( + + + , + ); + + // Real form is visible + expect( + screen.getByText("Upload your Chase CSV file"), + ).toBeInTheDocument(); + + // Select a CSV file and submit + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement | null; + expect(fileInput).not.toBeNull(); + const file = createChaseCsvFile(); + await waitFor(() => { + fireEvent.change(fileInput as HTMLInputElement, { target: { files: [file] } }); + }); + + fireEvent.click(screen.getByRole("button", { name: /next/i })); + + // After parsing, the ImportedTransactionsCard should appear + await waitFor(() => { + expect( + screen.getByText("Imported Transactions"), + ).toBeInTheDocument(); + }); + + // Tabs exist (labels include counts, so match by text part) + expect(screen.getByText(/Review needed/i)).toBeInTheDocument(); + expect(screen.getByText(/Importing/i)).toBeInTheDocument(); + expect(screen.getByText(/Skipping/i)).toBeInTheDocument(); + expect(screen.getByText(/Previously imported/i)).toBeInTheDocument(); + + // Click the back icon button (first button in the card header that is not the Import button) + screen.getByText("Imported Transactions").closest("div"); + const buttons = screen.getAllByRole("button"); + const importButton = screen.getByRole("button", { name: /import/i }); + const backButton = buttons.find((b) => b !== importButton) ?? buttons[0]; + fireEvent.click(backButton); + + // We should be back to the form + await waitFor(() => { + expect( + screen.getByText("Upload your Chase CSV file"), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/client/dashboard/src/components/transaction/import/import-transactions/import-transactions-page.tsx b/apps/client/dashboard/src/components/transaction/import/import-transactions/import-transactions-page.tsx new file mode 100644 index 0000000..7b0ddfa --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/import-transactions/import-transactions-page.tsx @@ -0,0 +1,91 @@ +import { ReactNode } from "react"; +import { useImmer } from "use-immer"; +import { ImportedTransactionsCard } from "./imported-transactions-card"; +import { useProfile } from "@/hooks/use-profile"; +import { + ChaseCardImporter, + ChaseAccountImporter, +} from "../importers/providers/chase"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { DraftTransaction, DraftTransactionStatus } from "./utils"; +import { ParsedTransaction } from "@monyfox/client-transactions-importer"; +import { needsReview } from "@/utils/imported-transaction"; + +export function ImportTransactionsPage({ importerId }: { importerId: string }) { + const { + data: { transactionsImporters }, + getImportedTransaction, + getAccount, + } = useProfile(); + const [draftTransactions, setDraftTransactions] = useImmer< + DraftTransaction[] + >([]); + + function onImport(parsedTransactions: ParsedTransaction[]) { + const draftTransactions = parsedTransactions.map((pt) => { + let status = DraftTransactionStatus.ReadyToImport; + + if (getImportedTransaction(pt.providerTransactionId) !== null) { + status = DraftTransactionStatus.SkippedAlreadyImported; + } else if (needsReview(pt, getAccount)) { + status = DraftTransactionStatus.NeedsReview; + } + + return { + ...pt, + status, + }; + }); + + setDraftTransactions(draftTransactions); + } + + const transactionsImporter = transactionsImporters.find( + (ti) => ti.id === importerId, + ); + + if (!transactionsImporter) { + return ( + + Importer not found + + ); + } + + let Form: ReactNode; + switch (transactionsImporter.data.provider) { + case "chase-card": { + Form = ( + + ); + break; + } + case "chase-account": { + Form = ( + + ); + break; + } + } + + return ( + <> + {draftTransactions.length === 0 ? ( + Form + ) : ( + setDraftTransactions([])} + /> + )} + + ); +} diff --git a/apps/client/dashboard/src/components/transaction/import/import-transactions/imported-transactions-card.test.tsx b/apps/client/dashboard/src/components/transaction/import/import-transactions/imported-transactions-card.test.tsx new file mode 100644 index 0000000..1f21ed9 --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/import-transactions/imported-transactions-card.test.tsx @@ -0,0 +1,378 @@ +import { describe, test, expect, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { TestContextProvider } from "@/utils/tests/contexts"; +import { useImmer } from "use-immer"; +import { renderHook } from "@testing-library/react"; +import { ImportedTransactionsCard } from "./imported-transactions-card"; +import { DraftTransaction, DraftTransactionStatus } from "./utils"; +import { TransactionType } from "@/utils/transaction"; +import { ulid } from "ulid"; +import { toast } from "sonner"; + +vi.mock("sonner", () => { + return { + toast: { + error: vi.fn(), + success: vi.fn(), + message: vi.fn(), + }, + }; +}); + +function makeTx( + type: "income" | "expense" | "transfer", + status: DraftTransactionStatus, +): DraftTransaction { + const id = ulid(); + + let from: DraftTransaction["from"]; + let to: DraftTransaction["to"]; + + switch (type) { + case "income": + from = { + amount: 10, + symbolId: "USD", + account: { name: "From account" }, + }; + to = { amount: 10, symbolId: "USD", account: { id: "ACCOUNT_1" } }; + break; + case "expense": + from = { amount: 10, symbolId: "USD", account: { id: "ACCOUNT_1" } }; + to = { amount: 10, symbolId: "USD", account: { name: "" } }; + break; + case "transfer": + from = { amount: 10, symbolId: "USD", account: { id: "ACCOUNT_1" } }; + to = { amount: 10, symbolId: "USD", account: { id: "ACCOUNT_2" } }; + break; + } + + return { + providerTransactionId: id, + date: "2024-01-01", + description: `Tx ${id}`, + from, + to, + transactionType: type, + transactionCategoryId: null, + status, + }; +} + +describe("ImportedTransactionsCard", () => { + let testTransactions: DraftTransaction[]; + + beforeEach(() => { + testTransactions = [ + makeTx(TransactionType.Transfer, DraftTransactionStatus.NeedsReview), + makeTx(TransactionType.Income, DraftTransactionStatus.ReadyToImport), + makeTx(TransactionType.Expense, DraftTransactionStatus.ReadyToImport), + makeTx( + TransactionType.Expense, + DraftTransactionStatus.SkippedTemporarily, + ), + makeTx(TransactionType.Income, DraftTransactionStatus.SkippedPermanently), + makeTx( + TransactionType.Transfer, + DraftTransactionStatus.SkippedPermanently, + ), + makeTx( + TransactionType.Expense, + DraftTransactionStatus.SkippedAlreadyImported, + ), + makeTx( + TransactionType.Income, + DraftTransactionStatus.SkippedAlreadyImported, + ), + makeTx( + TransactionType.Transfer, + DraftTransactionStatus.SkippedAlreadyImported, + ), + makeTx( + TransactionType.Expense, + DraftTransactionStatus.SkippedAlreadyImported, + ), + ]; + }); + + function expectTransactionsToBeInDocument( + expected: DraftTransaction[], + isInput: boolean, + ) { + const expectedDescriptions = expected.map((tx) => tx.description!); + const notExpectedDescriptions = testTransactions.filter( + (tx) => !expectedDescriptions.includes(tx.description!), + ); + + const fn = isInput ? screen.queryByDisplayValue : screen.queryByText; + + for (const tx of expected) { + expect(fn(tx.description!)).toBeInTheDocument(); + } + for (const tx of notExpectedDescriptions) { + expect(fn(tx.description!)).not.toBeInTheDocument(); + } + } + + test("renders transactions in tabs", async () => { + const { result } = renderHook(() => useImmer(testTransactions)); + const [transactions, setTransactions] = result.current; + + render( + + {}} + /> + , + ); + + screen.getByText("Review needed (1)"); + const importingTab = screen.getByRole("tab", { name: "Importing (2)" }); + const skippingTab = screen.getByRole("tab", { name: "Skipping (3)" }); + const importedTab = screen.getByRole("tab", { + name: "Previously imported (4)", + }); + + // Review needed + expectTransactionsToBeInDocument( + transactions.filter( + (tx) => tx.status === DraftTransactionStatus.NeedsReview, + ), + true, + ); + + // Importing + fireEvent.mouseDown(importingTab); + await waitFor(() => { + expectTransactionsToBeInDocument( + transactions.filter( + (tx) => tx.status === DraftTransactionStatus.ReadyToImport, + ), + true, + ); + }); + + // Skipping + fireEvent.mouseDown(skippingTab); + await waitFor(() => { + expectTransactionsToBeInDocument( + transactions.filter( + (tx) => + tx.status === DraftTransactionStatus.SkippedTemporarily || + tx.status === DraftTransactionStatus.SkippedPermanently, + ), + true, + ); + }); + + // Previously imported + fireEvent.mouseDown(importedTab); + await waitFor(() => { + expectTransactionsToBeInDocument( + transactions.filter( + (tx) => tx.status === DraftTransactionStatus.SkippedAlreadyImported, + ), + false, + ); + }); + }); + + test("shows error toast when trying to import with transactions needing review", async () => { + const { result } = renderHook(() => useImmer(testTransactions)); + const [transactions, setTransactions] = result.current; + + render( + + {}} + /> + , + ); + + const importBtn = screen.getByRole("button", { name: /import/i }); + fireEvent.click(importBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + `Please review all the transactions in the "Review needed" section.`, + ); + }); + }); + + test("Import button imports ready transactions and updates statuses", async () => { + const { result } = renderHook(() => + useImmer( + testTransactions.filter( + (t) => t.status === DraftTransactionStatus.ReadyToImport, + ), + ), + ); + let [transactions, setTransactions] = result.current; + + render( + + {}} + /> + , + ); + + expect(screen.getByText("Importing (2)")).toBeInTheDocument(); + expect(screen.getByText("Previously imported (0)")).toBeInTheDocument(); + + const importBtn = screen.getByRole("button", { name: /import/i }); + fireEvent.click(importBtn); + + await waitFor(() => { + // Pull the latest state + [transactions] = result.current; + + // All ready ones should become previously imported + expect( + transactions.every( + (t) => t.status === DraftTransactionStatus.SkippedAlreadyImported, + ), + ).toBe(true); + }); + }); + + test("imported transactions are moved between tabs", async () => { + render( + t.status === DraftTransactionStatus.ReadyToImport, + )} + />, + ); + + expect(screen.getByText("Importing (2)")).toBeInTheDocument(); + expect(screen.getByText("Previously imported (0)")).toBeInTheDocument(); + + const importBtn = screen.getByRole("button", { name: /import/i }); + fireEvent.click(importBtn); + + await waitFor(() => { + expect(screen.getByText("Importing (0)")).toBeInTheDocument(); + expect(screen.getByText("Previously imported (2)")).toBeInTheDocument(); + }); + }); + + test("Skip all button marks all transactions in review as temporarily skipped", async () => { + const { result } = renderHook(() => useImmer(testTransactions)); + let [transactions, setTransactions] = result.current; + + render( + + {}} + /> + , + ); + + expectTransactionsStatus( + { + [testTransactions[0].providerTransactionId]: + DraftTransactionStatus.NeedsReview, + [testTransactions[1].providerTransactionId]: + DraftTransactionStatus.ReadyToImport, + [testTransactions[2].providerTransactionId]: + DraftTransactionStatus.ReadyToImport, + [testTransactions[3].providerTransactionId]: + DraftTransactionStatus.SkippedTemporarily, + [testTransactions[4].providerTransactionId]: + DraftTransactionStatus.SkippedPermanently, + [testTransactions[5].providerTransactionId]: + DraftTransactionStatus.SkippedPermanently, + [testTransactions[6].providerTransactionId]: + DraftTransactionStatus.SkippedAlreadyImported, + [testTransactions[7].providerTransactionId]: + DraftTransactionStatus.SkippedAlreadyImported, + [testTransactions[8].providerTransactionId]: + DraftTransactionStatus.SkippedAlreadyImported, + [testTransactions[9].providerTransactionId]: + DraftTransactionStatus.SkippedAlreadyImported, + }, + transactions, + ); + + const skipAllBtn = screen.getByRole("button", { name: /skip all/i }); + fireEvent.click(skipAllBtn); + + await waitFor(() => { + // Pull the latest state + [transactions] = result.current; + + expectTransactionsStatus( + { + [testTransactions[0].providerTransactionId]: + DraftTransactionStatus.SkippedTemporarily, + [testTransactions[1].providerTransactionId]: + DraftTransactionStatus.ReadyToImport, + [testTransactions[2].providerTransactionId]: + DraftTransactionStatus.ReadyToImport, + [testTransactions[3].providerTransactionId]: + DraftTransactionStatus.SkippedTemporarily, + [testTransactions[4].providerTransactionId]: + DraftTransactionStatus.SkippedPermanently, + [testTransactions[5].providerTransactionId]: + DraftTransactionStatus.SkippedPermanently, + [testTransactions[6].providerTransactionId]: + DraftTransactionStatus.SkippedAlreadyImported, + [testTransactions[7].providerTransactionId]: + DraftTransactionStatus.SkippedAlreadyImported, + [testTransactions[8].providerTransactionId]: + DraftTransactionStatus.SkippedAlreadyImported, + [testTransactions[9].providerTransactionId]: + DraftTransactionStatus.SkippedAlreadyImported, + }, + transactions, + ); + }); + }); + + test("shows no transactions message when empty", () => { + render(); + + expect(screen.getByText(/No transactions found/i)).toBeInTheDocument(); + expect(screen.queryByText(/Review needed/i)).not.toBeInTheDocument(); + }); +}); + +function TestImportedTransactionsCard({ + initial, +}: { + initial: DraftTransaction[]; +}) { + const [transactions, setTransactions] = useImmer(initial); + return ( + + {}} + /> + + ); +} + +function expectTransactionsStatus( + idToExpectedStatus: { [key: string]: DraftTransactionStatus }, + transactions: DraftTransaction[], +) { + for (const tx of transactions) { + expect(tx.status).toBe(idToExpectedStatus[tx.providerTransactionId]); + } +} diff --git a/apps/client/dashboard/src/components/transaction/import/import-transactions/imported-transactions-card.tsx b/apps/client/dashboard/src/components/transaction/import/import-transactions/imported-transactions-card.tsx new file mode 100644 index 0000000..02528f5 --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/import-transactions/imported-transactions-card.tsx @@ -0,0 +1,342 @@ +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { type ParsedTransaction } from "@monyfox/client-transactions-importer"; +import { useProfile } from "@/hooks/use-profile"; +import { ArrowLeftIcon } from "lucide-react"; +import { + Alert, + AlertDescription, + DestructiveAlert, +} from "@/components/ui/alert"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Updater } from "use-immer"; +import { ulid } from "ulid"; +import { ImportedTransactionsTable } from "./imported-transactions-table"; +import { + Transaction, + ImportedTransaction as ImporterTransactionMetadata, +} from "@monyfox/common-data"; +import { TransactionsTable } from "../../transactions-table"; +import { toast } from "sonner"; +import { + DraftTransaction, + DraftTransactionStatus, + getDraftTransactionsByStatus, +} from "./utils"; +import { useMemo } from "react"; + +export function ImportedTransactionsCard({ + importerId, + draftTransactions, + setDraftTransactions, + onReset, +}: { + importerId: string; + draftTransactions: DraftTransaction[]; + setDraftTransactions: Updater; + onReset: () => void; +}) { + const { importTransactions } = useProfile(); + + const transactionsByStatus = useMemo( + () => getDraftTransactionsByStatus(draftTransactions), + [draftTransactions], + ); + + function onImport() { + if (transactionsByStatus[DraftTransactionStatus.NeedsReview].length > 0) { + toast.error( + `Please review all the transactions in the "Review needed" section.`, + ); + return; + } + + const now = new Date().toISOString(); + + let transactions: Transaction[] = []; + let importedTransactionsMetadata: ImporterTransactionMetadata[] = []; + + transactionsByStatus[DraftTransactionStatus.ReadyToImport].forEach((t) => { + const transactionId = ulid(); + + transactions.push({ + id: transactionId, + description: t.description ?? "", + transactionCategoryId: t.transactionCategoryId ?? null, + transactionDate: t.date ?? new Date().toISOString(), + accountingDate: t.date ?? new Date().toISOString(), + from: { + amount: t.from.amount ?? 0, + symbolId: t.from.symbolId ?? "", + account: + t.from.account !== undefined && "id" in t.from.account + ? { + id: t.from.account.id, + } + : { + name: t.from.account?.name ?? "N/A", + }, + }, + to: { + amount: t.to.amount ?? 0, + symbolId: t.to.symbolId ?? "", + account: + t.to.account !== undefined && "id" in t.to.account + ? { + id: t.to.account.id, + } + : { + name: t.to.account?.name ?? "N/A", + }, + }, + }); + + importedTransactionsMetadata.push({ + id: t.providerTransactionId, + importerId, + importedAt: now, + data: { + transactionId: transactionId, + status: "imported", + }, + }); + }); + + importTransactions.mutate( + { + transactions: transactions, + importedTransactions: importedTransactionsMetadata, + }, + { + onSuccess: () => + setDraftTransactions((dt) => + dt.map((t) => ({ + ...t, + status: + t.status === DraftTransactionStatus.ReadyToImport + ? DraftTransactionStatus.SkippedAlreadyImported + : t.status, + })), + ), + }, + ); + } + + if (draftTransactions.length === 0) { + return ; + } + + const reviewNeededTransactions = + transactionsByStatus[DraftTransactionStatus.NeedsReview]; + const readyToImportNeededTransactions = + transactionsByStatus[DraftTransactionStatus.ReadyToImport]; + const skippingTransactions = [ + ...transactionsByStatus[DraftTransactionStatus.SkippedTemporarily], + ...transactionsByStatus[DraftTransactionStatus.SkippedPermanently], + ]; + const previouslyImportedTransactions = + transactionsByStatus[DraftTransactionStatus.SkippedAlreadyImported]; + + return ( + + + +
Imported Transactions
+
+ + +
+
+
+ + + + + Review needed ({reviewNeededTransactions.length}) + + + Importing ({readyToImportNeededTransactions.length}) + + + Skipping ({skippingTransactions.length}) + + + Previously imported ({previouslyImportedTransactions.length}) + + + + + + These transactions need to be reviewed before they can be + imported. + + + {reviewNeededTransactions.length > 0 && ( +
+ +
+ )} + +
+ + + + These transactions are ready to be imported. You can still make + changes to them. + + + + + + + + These transactions are being skipped. + + + + + + + + These transactions are being skipped because they have been + imported in the past. + + + + +
+
+
+ ); +} + +function SkipAllReviewNeededButton({ + setTransactions, +}: { + setTransactions: Updater; +}) { + function onClick() { + setTransactions((dt) => + dt.map((t) => ({ + ...t, + status: + t.status === DraftTransactionStatus.NeedsReview + ? DraftTransactionStatus.SkippedTemporarily + : t.status, + })), + ); + } + + return ( + + ); +} + +function PreviouslyImportedTransactionsTable({ + transactions, +}: { + transactions: ParsedTransaction[]; +}) { + const { getImportedTransaction, getTransaction } = useProfile(); + + function toTransaction( + t: ParsedTransaction, + ): Transaction & { nonExistentText?: string } { + const existingImportedTransaction = getImportedTransaction( + t.providerTransactionId, + ); + + const existingTransaction = + existingImportedTransaction && + existingImportedTransaction.data.status === "imported" + ? getTransaction(existingImportedTransaction.data.transactionId) + : null; + + if (existingTransaction !== null) { + return existingTransaction; + } else { + return { + id: t.providerTransactionId, + description: t.description ?? "", + transactionCategoryId: t.transactionCategoryId ?? null, + transactionDate: t.date ?? new Date().toISOString(), + accountingDate: t.date ?? new Date().toISOString(), + from: { + amount: t.from.amount ?? 0, + symbolId: t.from.symbolId ?? "", + account: + t.from.account !== undefined && "id" in t.from.account + ? { + id: t.from.account.id, + } + : { + name: t.from.account?.name ?? "N/A", + }, + }, + to: { + amount: t.to.amount ?? 0, + symbolId: t.to.symbolId ?? "", + account: + t.to.account !== undefined && "id" in t.to.account + ? { + id: t.to.account.id, + } + : { + name: t.to.account?.name ?? "N/A", + }, + }, + nonExistentText: + existingImportedTransaction?.data.status === "imported" + ? "Deleted" + : existingImportedTransaction?.data.status === "skipped" + ? "Skipped" + : "Unknown", + }; + } + } + + return ; +} + +function NoTransactionsAlert({ onReset }: { onReset: () => void }) { + return ( + + + Imported Transactions + + + + No transactions were found in the file. Please check the file and try + again. +
+
+ +
+
+
+ ); +} diff --git a/apps/client/dashboard/src/components/transaction/import/import-transactions/imported-transactions-table.test.tsx b/apps/client/dashboard/src/components/transaction/import/import-transactions/imported-transactions-table.test.tsx new file mode 100644 index 0000000..e9e52e8 --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/import-transactions/imported-transactions-table.test.tsx @@ -0,0 +1,384 @@ +import { TestContextProvider } from "@/utils/tests/contexts"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ImportedTransactionsTable } from "./imported-transactions-table"; +import { + render, + fireEvent, + screen, + waitFor, + renderHook, +} from "@testing-library/react"; +import { TransactionType } from "@/utils/transaction"; +import { useImmer } from "use-immer"; +import { DraftTransaction, DraftTransactionStatus } from "./utils"; +import { toast } from "sonner"; + +vi.mock("sonner"); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("ImportedTransactionsTable", () => { + const mockTransactions: DraftTransaction[] = [ + { + providerTransactionId: "1", + date: "2023-01-01", + description: "Test Transaction 1", + from: { + amount: 100, + symbolId: "EUR", + account: { id: "ACCOUNT_1" }, + }, + to: { + amount: 100, + symbolId: "EUR", + account: { id: "ACCOUNT_2" }, + }, + transactionType: TransactionType.Transfer, + transactionCategoryId: null, + status: DraftTransactionStatus.ReadyToImport, + }, + { + providerTransactionId: "2", + date: "2023-01-02", + description: "Test Transaction 2", + from: { + amount: 200, + symbolId: "EUR", + account: { id: "ACCOUNT_1" }, + }, + to: { + amount: 200, + symbolId: "EUR", + account: { name: "" }, + }, + transactionType: TransactionType.Expense, + transactionCategoryId: null, + status: DraftTransactionStatus.ReadyToImport, + }, + { + providerTransactionId: "3", + date: "2023-01-03", + description: "Test Transaction 3", + from: { + amount: 200, + symbolId: "EUR", + account: { name: "" }, + }, + to: { + amount: 200, + symbolId: "EUR", + account: { id: "ACCOUNT_1" }, + }, + transactionType: TransactionType.Income, + transactionCategoryId: null, + status: DraftTransactionStatus.ReadyToImport, + }, + { + providerTransactionId: "4", + date: "2023-01-03", + description: "Test Transaction 4", + from: { + amount: 200, + symbolId: "EUR", + account: undefined, + }, + to: { + amount: 200, + symbolId: "EUR", + account: undefined, + }, + transactionType: TransactionType.Income, + transactionCategoryId: null, + status: DraftTransactionStatus.NeedsReview, + }, + ]; + + async function renderComponent(initialTransactions = mockTransactions) { + const { result } = renderHook(() => + useImmer(initialTransactions), + ); + let [transactions, setTransactions] = result.current; + + render( + + + , + ); + + await waitFor(() => { + for (const transaction of initialTransactions) { + screen.getByDisplayValue(transaction.description!); + } + }); + + return result; + } + + test("updates transaction date", async () => { + const result = await renderComponent(); + + const dateInput = screen.getByDisplayValue("2023-01-01"); + fireEvent.change(dateInput, { target: { value: "2023-01-03" } }); + + await waitFor(() => { + const [transactions] = result.current; + expect(transactions[0].date).toBe("2023-01-03"); + }); + }); + + test("updates transaction from account", async () => { + const result = await renderComponent(); + + const [transactions] = result.current; + expect(transactions[0].from.account).toEqual({ id: "ACCOUNT_1" }); + + const fromAccountSelect = screen.getByText("Account 1"); + fireEvent.click(fromAccountSelect); + fireEvent.click(screen.getAllByText("Account 2")[1]); + + await waitFor(() => { + const [transactions] = result.current; + expect(transactions[0].from.account).toEqual({ id: "ACCOUNT_2" }); + }); + }); + + test("updates transaction to account", async () => { + const result = await renderComponent(); + + const [transactions] = result.current; + expect(transactions[0].to.account).toEqual({ id: "ACCOUNT_2" }); + + const toAccountSelect = screen.getByText("Account 2"); + fireEvent.click(toAccountSelect); + fireEvent.click(screen.getAllByText("Account 1")[1]); + + await waitFor(() => { + const [transactions] = result.current; + expect(transactions[0].to.account).toEqual({ id: "ACCOUNT_1" }); + }); + }); + + test("updates transaction description", async () => { + const result = await renderComponent(); + + const descriptionInput = screen.getByDisplayValue("Test Transaction 1"); + fireEvent.change(descriptionInput, { + target: { value: "Updated Transaction 1" }, + }); + + await waitFor(() => { + const [transactions] = result.current; + expect(transactions[0].description).toBe("Updated Transaction 1"); + }); + }); + + test("updates transaction amount", async () => { + const result = await renderComponent(); + + const amountInput = screen.getByDisplayValue("100.00"); + fireEvent.change(amountInput, { target: { value: "150.00" } }); + + await waitFor(() => { + const [transactions] = result.current; + expect(transactions[0].from.amount).toBe(150); + expect(transactions[0].to.amount).toBe(150); + }); + }); + + test("updates transaction category", async () => { + const result = await renderComponent(); + + const categorySelect = screen.getAllByText("(None)")[0]; + fireEvent.click(categorySelect); + fireEvent.click( + screen.getByText("- Subcategory 1-1", { selector: "span" }), + ); + + await waitFor(() => { + const [transactions] = result.current; + expect(transactions[0].transactionCategoryId).toBe("CATEGORY_1_1"); + }); + }); + + test("updates transaction type and updates status when needed", async () => { + const result = await renderComponent(); + + let [transactions] = result.current; + expect(transactions[0].transactionType).toBe(TransactionType.Transfer); + expect(transactions[0].status).toBe(DraftTransactionStatus.ReadyToImport); + + const typeSelect = screen.getByText("Transfer"); + fireEvent.click(typeSelect); + fireEvent.click(screen.getAllByText("Expense")[1]); + + await waitFor(() => { + [transactions] = result.current; + expect(transactions[0].transactionType).toBe(TransactionType.Expense); + // Changing from Transfer -> Expense should move to Review needed + expect(transactions[0].status).toBe(DraftTransactionStatus.NeedsReview); + expect(toast.warning).toHaveBeenCalled(); + }); + }); + + test("changing Expense ↔ Income swaps from/to but keeps status", async () => { + const { result } = renderHook(() => + useImmer([ + { + providerTransactionId: "X1", + date: "2023-01-10", + description: "Expense 1", + from: { amount: 10, symbolId: "USD", account: { id: "ACCOUNT_1" } }, + to: { amount: 10, symbolId: "USD", account: { name: "Vendor" } }, + transactionType: TransactionType.Expense, + transactionCategoryId: null, + status: DraftTransactionStatus.ReadyToImport, + }, + ]), + ); + + let [transactions, setTransactions] = result.current; + + render( + + + , + ); + + // Open type selects and chooses Income + fireEvent.click(screen.getByText("Expense")); + fireEvent.click(screen.getAllByText("Income")[0]); + + await waitFor(() => { + [transactions] = result.current; + expect(transactions[0].transactionType).toBe(TransactionType.Income); + // Status should remain ReadyToImport for expense<->income + expect(transactions[0].status).toBe(DraftTransactionStatus.ReadyToImport); + // from/to swapped + const fromHasName = "name" in (transactions[0].from.account as any); + const toHasId = "id" in (transactions[0].to.account as any); + expect(fromHasName).toBe(true); + expect(toHasId).toBe(true); + }); + }); + + describe("Actions cell", () => { + test("ReadyToImport → Skip sets SkippedTemporarily", async () => { + const result = await renderComponent([mockTransactions[0]]); + + const skipBtn = screen.getByTestId("skip-button"); + fireEvent.click(skipBtn); + + await waitFor(() => { + const [transactions] = result.current; + expect(transactions[0].status).toBe( + DraftTransactionStatus.SkippedTemporarily, + ); + }); + }); + + test("NeedsReview + invalid → Mark as reviewed shows error and stays NeedsReview", async () => { + const result = await renderComponent([ + { + providerTransactionId: "A1", + date: "2024-01-01", + description: "Tx", + from: { amount: 1, account: { id: "ACCOUNT_1" } }, // missing symbolId -> invalid + to: { amount: 1, symbolId: "USD", account: { name: "Store" } }, + transactionType: TransactionType.Expense, + transactionCategoryId: null, + status: DraftTransactionStatus.NeedsReview, + }, + ]); + + const markReviewedBtn = screen.getByTestId("mark-reviewed-button"); + fireEvent.click(markReviewedBtn); + + await waitFor(() => { + const [transactions] = result.current; + expect(transactions[0].status).toBe(DraftTransactionStatus.NeedsReview); + expect(toast.error).toHaveBeenCalledWith( + "Please fix the errors before marking as reviewed", + ); + }); + }); + + test("NeedsReview + valid → Mark as reviewed sets ReadyToImport", async () => { + const result = await renderComponent([ + { ...mockTransactions[0], status: DraftTransactionStatus.NeedsReview }, + ]); + + const markReviewedBtn = screen.getByTestId("mark-reviewed-button"); + fireEvent.click(markReviewedBtn); + + await waitFor(() => { + const [transactions] = result.current; + expect(toast.error).not.toHaveBeenCalled(); + expect(transactions[0].status).toBe( + DraftTransactionStatus.ReadyToImport, + ); + }); + }); + + test("SkippedTemporarily + valid → Do not skip sets ReadyToImport", async () => { + const result = await renderComponent([ + { + ...mockTransactions[0], + status: DraftTransactionStatus.SkippedTemporarily, + }, + ]); + + const doNotSkipBtn = screen.getByTestId("do-not-skip-button"); + fireEvent.click(doNotSkipBtn); + + await waitFor(() => { + const [transactions] = result.current; + expect(transactions[0].status).toBe( + DraftTransactionStatus.ReadyToImport, + ); + }); + }); + + test("SkippedTemporarily + invalid → Do not skip sets NeedsReview", async () => { + const result = await renderComponent([ + { + ...mockTransactions[0], + date: undefined, // missing date -> invalid + status: DraftTransactionStatus.SkippedTemporarily, + }, + ]); + + const doNotSkipBtn = screen.getByTestId("do-not-skip-button"); + fireEvent.click(doNotSkipBtn); + + await waitFor(() => { + const [transactions] = result.current; + expect(transactions[0].status).toBe(DraftTransactionStatus.NeedsReview); + }); + }); + + test("SkippedAlreadyImported → no action buttons rendered", async () => { + await renderComponent([ + { + ...mockTransactions[0], + status: DraftTransactionStatus.SkippedAlreadyImported, + }, + ]); + + expect( + screen.queryByTestId("do-not-skip-button"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("skip-button")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("mark-reviewed-button"), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/client/dashboard/src/components/transaction/import/import-transactions/imported-transactions-table.tsx b/apps/client/dashboard/src/components/transaction/import/import-transactions/imported-transactions-table.tsx new file mode 100644 index 0000000..0a4f93a --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/import-transactions/imported-transactions-table.tsx @@ -0,0 +1,428 @@ +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { TransactionType } from "@/utils/transaction"; +import { useProfile } from "@/hooks/use-profile"; +import { ArrowRightIcon, CheckIcon, CircleOffIcon } from "lucide-react"; +import { DataTable } from "@/components/data-table"; +import { ColumnDef } from "@tanstack/react-table"; +import { createContext, useContext } from "react"; +import { Updater } from "use-immer"; +import { WritableDraft } from "immer"; +import { SelectItemTransactionCategoryWithChildren } from "@/components/settings/transaction-categories/category-select-item"; +import { getTransactionCategoriesWithChildren } from "@/utils/transaction-category"; +import { DraftTransaction, DraftTransactionStatus } from "./utils"; +import { Button } from "@/components/ui/button"; +import { needsReview } from "@/utils/imported-transaction"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { toast } from "sonner"; + +export function ImportedTransactionsTable({ + transactions, + setTransactions, +}: { + transactions: DraftTransaction[]; + setTransactions: Updater; +}) { + function updateTransaction( + id: string, + updater: (draft: DraftTransaction) => void, + ) { + setTransactions((draft) => { + const t = draft?.find((t) => t.providerTransactionId === id); + if (t) { + updater(t); + } + }); + } + + return ( + + r.providerTransactionId} + // Prevent the table from resetting the page index when the data changes on input updates. + options={{ autoResetPageIndex: false }} + /> + + ); +} + +type ImportedTransactionsContext = { + transactions: DraftTransaction[]; + updateTransaction: ( + id: string, + updater: (draft: WritableDraft) => void, + ) => void; +}; + +const ImportedTransactionsContext = + createContext(null); + +const useImportedTransactions = () => { + const context = useContext(ImportedTransactionsContext); + if (!context) { + throw new Error( + "useImportedTransactions must be used within a ImportedTransactionsProvider", + ); + } + return context; +}; + +function DateCell({ transaction }: { transaction: DraftTransaction }) { + const { updateTransaction } = useImportedTransactions(); + + function onChangeDate(e: React.ChangeEvent) { + updateTransaction(transaction.providerTransactionId, (draft) => { + draft.date = e.target.value; + }); + } + + return ( + + ); +} + +function DescriptionCell({ transaction }: { transaction: DraftTransaction }) { + const { + data: { accounts }, + } = useProfile(); + const { updateTransaction } = useImportedTransactions(); + + function onChangeDescription(e: React.ChangeEvent) { + updateTransaction(transaction.providerTransactionId, (draft) => { + draft.description = e.target.value; + }); + } + + function onChangeFromAccount(accountId: string) { + updateTransaction(transaction.providerTransactionId, (draft) => { + draft.from.account = accountId === "-" ? { name: "" } : { id: accountId }; + }); + } + + function onChangeToAccount(accountId: string) { + updateTransaction(transaction.providerTransactionId, (draft) => { + draft.to.account = accountId === "-" ? { name: "" } : { id: accountId }; + }); + } + + const fromAccountId = + transaction.from.account !== undefined && "id" in transaction.from.account + ? transaction.from.account.id + : "-"; + const toAccountId = + transaction.to.account !== undefined && "id" in transaction.to.account + ? transaction.to.account.id + : "-"; + + return ( + <> + + {transaction.transactionType === "transfer" && ( + <> +
+
+
+ +
+ +
+ +
+
+ + )} + + ); +} + +function AmountCell({ transaction }: { transaction: DraftTransaction }) { + const { updateTransaction } = useImportedTransactions(); + + function onChangeAmount(e: React.ChangeEvent) { + const amount = parseFloat(e.target.value); + if (isNaN(amount)) { + return; + } + + updateTransaction(transaction.providerTransactionId, (draft) => { + draft.from.amount = amount; + draft.to.amount = amount; + }); + } + + return ( + + ); +} + +function CategoryCell({ transaction }: { transaction: DraftTransaction }) { + const { + data: { transactionCategories }, + } = useProfile(); + const { updateTransaction } = useImportedTransactions(); + + function onChangeCategory(categoryId: string) { + updateTransaction(transaction.providerTransactionId, (draft) => { + draft.transactionCategoryId = categoryId === "-" ? null : categoryId; + }); + } + + const rootCategories = getTransactionCategoriesWithChildren( + transactionCategories, + ); + + return ( + + ); +} + +function TypeCell({ transaction }: { transaction: DraftTransaction }) { + const { updateTransaction } = useImportedTransactions(); + + function onChangeType(value: TransactionType) { + updateTransaction(transaction.providerTransactionId, (draft) => { + if ( + (draft.transactionType === "expense" && value === "income") || + (draft.transactionType === "income" && value === "expense") + ) { + const previousFrom = draft.from; + draft.from = draft.to; + draft.to = previousFrom; + } else if (draft.status !== DraftTransactionStatus.NeedsReview) { + toast.warning(`Transaction moved to "Review needed" section.`); + draft.status = DraftTransactionStatus.NeedsReview; + } + + draft.transactionType = value as DraftTransaction["transactionType"]; + }); + } + + let typeBgColor = ""; + switch (transaction.transactionType) { + case TransactionType.Income: + typeBgColor = "bg-green-50"; + break; + case TransactionType.Expense: + typeBgColor = "bg-red-50"; + break; + case TransactionType.Transfer: + typeBgColor = "bg-blue-50"; + break; + } + + return ( + + ); +} + +function ActionsCell({ transaction }: { transaction: DraftTransaction }) { + const { getAccount } = useProfile(); + const { updateTransaction } = useImportedTransactions(); + + function onChangeStatus(value: DraftTransactionStatus) { + updateTransaction(transaction.providerTransactionId, (draft) => { + draft.status = value; + }); + } + + function onDoNotSkip() { + if (needsReview(transaction, getAccount)) { + onChangeStatus(DraftTransactionStatus.NeedsReview); + } else { + onChangeStatus(DraftTransactionStatus.ReadyToImport); + } + } + + function onSkip() { + onChangeStatus(DraftTransactionStatus.SkippedTemporarily); + } + + function onReviewed() { + if (needsReview(transaction, getAccount)) { + toast.error("Please fix the errors before marking as reviewed"); + return; + } + + onChangeStatus(DraftTransactionStatus.ReadyToImport); + } + + if (transaction.status === DraftTransactionStatus.SkippedAlreadyImported) { + return null; + } + + if ( + transaction.status === DraftTransactionStatus.SkippedTemporarily || + transaction.status === DraftTransactionStatus.SkippedPermanently + ) { + return ( + + + + + Do not skip + + ); + } + + if (transaction.status === DraftTransactionStatus.NeedsReview) { + return ( +
+ + + + + Mark as reviewed + + + + + + Skip + +
+ ); + } + + return ( + + + + + Skip + + ); +} + +const columns: ColumnDef[] = [ + { + accessorKey: "date", + header: "Date", + cell: ({ row }) => , + }, + { + accessorKey: "description", + header: "Description", + cell: ({ row }) => , + }, + { + accessorKey: "amount", + header: "Amount", + cell: ({ row }) => , + }, + { + accessorKey: "transactionCategoryId", + header: "Category", + cell: ({ row }) => , + }, + { + accessorKey: "type", + header: "Type", + cell: ({ row }) => , + }, + { + accessorKey: "actions", + header: "", + cell: ({ row }) => , + }, +]; diff --git a/apps/client/dashboard/src/components/transaction/import/import-transactions/utils.test.ts b/apps/client/dashboard/src/components/transaction/import/import-transactions/utils.test.ts new file mode 100644 index 0000000..1b24aec --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/import-transactions/utils.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + DraftTransactionStatus, + DraftTransaction, + getDraftTransactionsByStatus, +} from "./utils"; +import { ParsedTransaction } from "@monyfox/client-transactions-importer"; + +const parsedTransaction: ParsedTransaction = { + providerTransactionId: "id", + transactionType: "income", + from: {}, + to: {}, +}; + +describe("getDraftTransactionsByStatus", () => { + it("should group transactions by their status", () => { + const transactions: DraftTransaction[] = [ + { ...parsedTransaction, status: DraftTransactionStatus.NeedsReview }, + { ...parsedTransaction, status: DraftTransactionStatus.ReadyToImport }, + { + ...parsedTransaction, + status: DraftTransactionStatus.SkippedAlreadyImported, + }, + { + ...parsedTransaction, + status: DraftTransactionStatus.SkippedTemporarily, + }, + { + ...parsedTransaction, + status: DraftTransactionStatus.SkippedPermanently, + }, + { + ...parsedTransaction, + status: DraftTransactionStatus.SkippedAlreadyImported, + }, + { + ...parsedTransaction, + status: DraftTransactionStatus.SkippedPermanently, + }, + ]; + + const result = getDraftTransactionsByStatus(transactions); + + expect(result[DraftTransactionStatus.NeedsReview]).toHaveLength(1); + expect(result[DraftTransactionStatus.ReadyToImport]).toHaveLength(1); + expect(result[DraftTransactionStatus.SkippedAlreadyImported]).toHaveLength( + 2, + ); + expect(result[DraftTransactionStatus.SkippedTemporarily]).toHaveLength(1); + expect(result[DraftTransactionStatus.SkippedPermanently]).toHaveLength(2); + }); +}); diff --git a/apps/client/dashboard/src/components/transaction/import/import-transactions/utils.ts b/apps/client/dashboard/src/components/transaction/import/import-transactions/utils.ts new file mode 100644 index 0000000..fb522ed --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/import-transactions/utils.ts @@ -0,0 +1,40 @@ +import { ParsedTransaction } from "@monyfox/client-transactions-importer"; + +export enum DraftTransactionStatus { + /** The transaction can't be imported and needs to be manually reviewed. */ + NeedsReview = "needs-review", + + /** The transactio is ready to be imported. */ + ReadyToImport = "ready-to-import", + + /** The transaction has already been imported (or skipped permanently) in the + * past. */ + SkippedAlreadyImported = "skipped-already-imported", + + /** The user wants to skip the import this time, but the transaction will be + * processed again next time. */ + SkippedTemporarily = "skipped-temporarily", + + /** The transaction will always be skipped in the future. */ + SkippedPermanently = "skipped-permanently", +} + +export type DraftTransaction = ParsedTransaction & { + status: DraftTransactionStatus; +}; + +export function getDraftTransactionsByStatus(transactions: DraftTransaction[]) { + const result = { + [DraftTransactionStatus.NeedsReview]: [] as DraftTransaction[], + [DraftTransactionStatus.ReadyToImport]: [] as DraftTransaction[], + [DraftTransactionStatus.SkippedAlreadyImported]: [] as DraftTransaction[], + [DraftTransactionStatus.SkippedTemporarily]: [] as DraftTransaction[], + [DraftTransactionStatus.SkippedPermanently]: [] as DraftTransaction[], + } as const; + + transactions.forEach((t) => { + result[t.status].push(t); + }); + + return result; +} diff --git a/apps/client/dashboard/src/components/transaction/import/importers/create-importer-card.test.tsx b/apps/client/dashboard/src/components/transaction/import/importers/create-importer-card.test.tsx new file mode 100644 index 0000000..2080799 --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/importers/create-importer-card.test.tsx @@ -0,0 +1,26 @@ +import { describe, test, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { TestContextProvider } from "@/utils/tests/contexts"; +import { CreateImporterCard } from "./create-importer-card"; + +describe("CreateImporterCard", () => { + test("shows Chase importer link and navigates to create page", async () => { + render( + + + , + ); + + // The card title and description + expect(screen.getByText("Create new importer")).toBeInTheDocument(); + expect( + screen.getByText( + /Select one of the available importers to create a new importer/i, + ), + ).toBeInTheDocument(); + + // The Chase tile and plus button + expect(screen.getByText("Chase")).toBeInTheDocument(); + screen.getByTitle("Create Chase importer"); + }); +}); diff --git a/apps/client/dashboard/src/components/transaction/import/importers/create-importer-card.tsx b/apps/client/dashboard/src/components/transaction/import/importers/create-importer-card.tsx new file mode 100644 index 0000000..aa63763 --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/importers/create-importer-card.tsx @@ -0,0 +1,71 @@ +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useProfile } from "@/hooks/use-profile"; +import { TransactionsImporter } from "@monyfox/common-data"; +import { Link } from "@tanstack/react-router"; +import { PlusIcon } from "lucide-react"; + +export function CreateImporterCard() { + return ( + + + Create new importer + + Select one of the available importers to create a new importer. + + + +
+ + +
+
+
+ ); +} + +function CreateImporterLink({ + name, + importerType, +}: { + name: string; + importerType: TransactionsImporter["data"]["provider"]; +}) { + const { + user: { id: profileId }, + } = useProfile(); + + return ( + + + + {name} + + + + + + + ); +} diff --git a/apps/client/dashboard/src/components/transaction/import/importers/create-new-importer-page.test.tsx b/apps/client/dashboard/src/components/transaction/import/importers/create-new-importer-page.test.tsx new file mode 100644 index 0000000..e6bcad2 --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/importers/create-new-importer-page.test.tsx @@ -0,0 +1,79 @@ +import { describe, test, expect, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { TestContextProvider } from "@/utils/tests/contexts"; +import { CreateNewImporterPage } from "./create-new-importer-page"; +import { useProfile } from "@/hooks/use-profile.ts"; + +function fillAndSubmitChaseForm() { + const nameInput = screen.getByLabelText(/Name of the credit card/i); + fireEvent.change(nameInput, { target: { value: "My Chase" } }); + + // Select account + const accountSelectTrigger = screen.getByText(/Select an account/i); + fireEvent.click(accountSelectTrigger); + fireEvent.click(screen.getAllByText("Account 1")[1]); + + // Select currency + const currencyTrigger = screen.getByText(/Select a currency/i); + fireEvent.click(currencyTrigger); + fireEvent.click(screen.getAllByText("USD")[1]); + + const submit = screen.getByText("Create"); + fireEvent.click(submit); +} + +describe("CreateNewImporterPage", () => { + test("unsupported importer type shows alert", () => { + render( + + {}} + /> + , + ); + + expect( + screen.getByText(/Importer type "unknown-provider" not supported/i), + ).toBeInTheDocument(); + }); + + test("chase-card: creates importer and navigates back to dashboard", async () => { + const onSuccess = vi.fn(); + render( + + + + , + ); + expect(screen.getByText("Importers:Importer 1.")).toBeInTheDocument(); + + // Form visible + expect( + screen.getByText("Create a Chase Credit Card importer"), + ).toBeInTheDocument(); + + // Fill and submit + fillAndSubmitChaseForm(); + + // After creation, navigation goes to the dashboard page + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledOnce(); + expect( + screen.getByText("Importers:Importer 1,My Chase."), + ).toBeInTheDocument(); + }); + }); +}); + +function PrintImportersForTest() { + const { + data: { transactionsImporters }, + } = useProfile(); + return ( +
Importers:{transactionsImporters.map((i) => i.name).join(",")}.
+ ); +} diff --git a/apps/client/dashboard/src/components/transaction/import/importers/create-new-importer-page.tsx b/apps/client/dashboard/src/components/transaction/import/importers/create-new-importer-page.tsx new file mode 100644 index 0000000..4a64e4b --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/importers/create-new-importer-page.tsx @@ -0,0 +1,37 @@ +import { toast } from "sonner"; +import { ChaseCardImporter, ChaseAccountImporter } from "./providers/chase"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +export function CreateNewImporterPage({ + importerType, + onSuccess, +}: { + importerType: string; + onSuccess: () => void; +}) { + function onError(e: Error) { + console.error(e); + toast.error(e.message); + } + + if (importerType === "chase-card") { + return ( + + ); + } else if (importerType === "chase-account") { + return ( + + ); + } + + return ( + + + Importer type "{importerType}" not supported + + + ); +} diff --git a/apps/client/dashboard/src/components/transaction/import/importers/importer-card.tsx b/apps/client/dashboard/src/components/transaction/import/importers/importer-card.tsx new file mode 100644 index 0000000..d1fd7d2 --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/importers/importer-card.tsx @@ -0,0 +1,70 @@ +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardTitle } from "@/components/ui/card"; +import { Modal, useModal } from "@/components/ui/modal"; +import { useProfile } from "@/hooks/use-profile"; +import { TransactionsImporter } from "@monyfox/common-data"; +import { Link } from "@tanstack/react-router"; +import { ArrowRightIcon, PencilIcon } from "lucide-react"; +import { toast } from "sonner"; +import { ChaseCardImporter, ChaseAccountImporter } from "./providers/chase"; + +export function Importer({ importer }: { importer: TransactionsImporter }) { + const { + user: { id: profileId }, + } = useProfile(); + const { closeModal, openModal, isOpen } = useModal(); + + function onSuccess() { + closeModal(); + } + + function onError(e: Error) { + console.error(e); + toast.error(e.message); + } + + return ( + <> + + + + {importer.name} +
+ + + + +
+
+
+
+ + {importer.data.provider === "chase-card" ? ( + + ) : importer.data.provider === "chase-account" ? ( + + ) : null} + + + ); +} diff --git a/apps/client/dashboard/src/components/transaction/import/importers/providers/chase.test.tsx b/apps/client/dashboard/src/components/transaction/import/importers/providers/chase.test.tsx new file mode 100644 index 0000000..8c6dec5 --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/importers/providers/chase.test.tsx @@ -0,0 +1,68 @@ +import { describe, test, expect } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { TestContextProvider } from "@/utils/tests/contexts"; +import { useProfile } from "@/hooks/use-profile"; +import { useState } from "react"; +import { ChaseCardImporter } from "./chase"; + +function createChaseCsvFile() { + const headers = [ + "Transaction Date", + "Post Date", + "Description", + "Category", + "Type", + "Amount", + "Memo", + ].join(","); + + const rows = [ + ["09/12/2025", "09/14/2025", "MERCHANT 1", "Shopping", "Sale", "-21.45", ""], + ["09/12/2025", "09/14/2025", "MERCHANT 2", "Food & Drink", "Return", "25.00", ""], + ["08/01/2025", "08/03/2025", "Payment Thank You-Mobile", "", "Payment", "50.00", ""], + ] + .map((r) => r.join(",")) + .join("\n"); + + const content = `${headers}\n${rows}\n`; + return new File([content], "chase.csv", { type: "text/csv" }); +} + +function ImportFormHarness() { + const { data } = useProfile(); + const importer = data.transactionsImporters[0]; + const [count, setCount] = useState(null); + + return ( + <> + setCount(txs.length)} + /> +
{count ?? "no"}
+ + ); +} + +describe("ChaseCardImporter.ImportForm", () => { + test("parses CSV and returns transactions via onSuccess", async () => { + render( + + + , + ); + + // Upload CSV + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement | null; + expect(fileInput).not.toBeNull(); + const file = createChaseCsvFile(); + fireEvent.change(fileInput as HTMLInputElement, { target: { files: [file] } }); + + // Submit + fireEvent.click(screen.getByRole("button", { name: /next/i })); + + await waitFor(() => { + expect(screen.getByTestId("parsed-count").textContent).toBe("3"); + }); + }); +}); diff --git a/apps/client/dashboard/src/components/transaction/import/importers/providers/chase.tsx b/apps/client/dashboard/src/components/transaction/import/importers/providers/chase.tsx new file mode 100644 index 0000000..ff45a13 --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/importers/providers/chase.tsx @@ -0,0 +1,414 @@ +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { useMutation } from "@tanstack/react-query"; +import React, { useRef } from "react"; +import { toast } from "sonner"; +import { + ChaseCardTransactionsImporter, + ChaseAccountTransactionsImporter, + type ParsedTransaction, + TransactionsImporter as GenericTransactionsImporter, +} from "@monyfox/client-transactions-importer"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useProfile } from "@/hooks/use-profile"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useForm } from "react-hook-form"; +import z from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ulid } from "ulid"; +import { TransactionsImporter } from "@monyfox/common-data"; +import { TrashIcon } from "lucide-react"; + +const ImporterFormSchema = z.object({ + name: z.string(), + accountId: z.string(), + symbolId: z.string(), +}); +type ImporterForm = z.infer; + +const CreateForm = + (provider: "chase-card" | "chase-account") => + ({ + onSuccess, + onError, + }: { + onSuccess: () => void; + onError: (e: Error) => void; + }) => { + const { + data: { accounts, assetSymbols }, + createTransactionsImporters, + } = useProfile(); + + const form = useForm({ + resolver: zodResolver(ImporterFormSchema), + }); + + const submit = useMutation({ + mutationFn: async ({ accountId, symbolId, name }: ImporterForm) => { + await createTransactionsImporters.mutateAsync([ + { + id: ulid(), + name: name, + data: { + provider: provider, + defaultAccountId: accountId, + defaultSymbolId: symbolId, + }, + }, + ]); + }, + onSuccess, + onError, + }); + + function onSubmit(values: ImporterForm) { + submit.mutate(values); + } + + const title = + provider === "chase-card" + ? "Create a Chase Credit Card importer" + : "Create a Chase Account importer"; + + const nameLabel = + provider === "chase-card" + ? "Name of the credit card" + : "Name of the account"; + + return ( +
+ + + + {title} + + + + ( + + {nameLabel} + + + + + + )} + /> + ( + + Link to account + + + + + + )} + /> + ( + + Currency + + + + + + )} + /> + + + + + +
+ + ); + }; + +const EditForm = + (provider: "chase-card" | "chase-account") => + ({ + importer, + onSuccess, + onError, + }: { + importer: TransactionsImporter & { data: { provider: typeof provider } }; + onSuccess: () => void; + onError: (e: Error) => void; + }) => { + const { + data: { accounts, assetSymbols }, + updateTransactionsImporter, + deleteTransactionsImporter, + } = useProfile(); + + const form = useForm({ + resolver: zodResolver(ImporterFormSchema), + defaultValues: { + name: importer.name, + accountId: importer.data.defaultAccountId, + symbolId: importer.data.defaultSymbolId, + }, + }); + + const submit = useMutation({ + mutationFn: async (input: ImporterForm) => { + await updateTransactionsImporter.mutateAsync({ + id: importer.id, + name: input.name, + data: { + provider: provider, + defaultAccountId: input.accountId, + defaultSymbolId: input.symbolId, + }, + }); + }, + onSuccess, + onError, + }); + + function onSubmit(values: ImporterForm) { + submit.mutate(values); + } + + function onDelete() { + deleteTransactionsImporter.mutate(importer.id, { + onSuccess, + onError: (e) => { + console.error(e); + toast.error(e.message); + }, + }); + } + + return ( +
+ +
+ ( + + Name + + + + + + )} + /> + ( + + Link to account + + + + + + )} + /> + ( + + Currency + + + + + + )} + /> +
+ + +
+
+
+ + ); + }; + +const ImportForm = + (provider: "chase-card" | "chase-account") => + ({ + transactionsImporter, + onSuccess, + }: { + transactionsImporter: TransactionsImporter & { + data: { provider: typeof provider }; + }; + onSuccess: (transactions: ParsedTransaction[]) => void; + }) => { + const fileRef = useRef(null); + + const submit = useMutation({ + mutationFn: async () => { + const file = fileRef.current?.files?.[0]; + if (file === undefined) { + throw new Error("No file selected"); + } + + let importer: GenericTransactionsImporter; + + switch (provider) { + case "chase-card": + importer = new ChaseCardTransactionsImporter({ + accountId: transactionsImporter.data.defaultAccountId, + symbolId: transactionsImporter.data.defaultSymbolId, + }); + break; + case "chase-account": + importer = new ChaseAccountTransactionsImporter({ + accountId: transactionsImporter.data.defaultAccountId, + symbolId: transactionsImporter.data.defaultSymbolId, + }); + break; + } + + const transactions = await importer.getTransactions(file); + onSuccess(transactions); + }, + onError: (e) => { + console.error(e); + toast.error(e.message); + }, + }); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + submit.mutate(); + } + + return ( +
+ + + Upload your Chase CSV file + + + + + + + + + +
+ ); + }; + +export const ChaseCardImporter = { + CreateForm: CreateForm("chase-card"), + EditForm: EditForm("chase-card"), + ImportForm: ImportForm("chase-card"), +}; + +export const ChaseAccountImporter = { + CreateForm: CreateForm("chase-account"), + EditForm: EditForm("chase-account"), + ImportForm: ImportForm("chase-account"), +}; diff --git a/apps/client/dashboard/src/components/transaction/import/importers/user-importers-card.test.tsx b/apps/client/dashboard/src/components/transaction/import/importers/user-importers-card.test.tsx new file mode 100644 index 0000000..8e86e33 --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/importers/user-importers-card.test.tsx @@ -0,0 +1,75 @@ +import { describe, test, expect } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { TestContextProvider } from "@/utils/tests/contexts"; +import { UserImportersCard } from "./user-importers-card"; + +describe("UserImportersCard", () => { + test("lists existing importers and allows opening edit modal and navigating to import page", async () => { + render( + + + , + ); + + // Title and description + expect(screen.getByText("Your importers")).toBeInTheDocument(); + expect( + screen.getByText(/Select one of the available importers/i), + ).toBeInTheDocument(); + + // One importer present + expect(screen.getByText("Importer 1")).toBeInTheDocument(); + + // Open edit modal via pencil icon button + const buttons = screen.getAllByRole("button"); + const pencil = buttons.find((b) => b.querySelector("svg")); + if (pencil) fireEvent.click(pencil); + + await waitFor(() => { + expect(screen.getByText("Edit importer")).toBeInTheDocument(); + }); + + // Close modal by updating the name (submit will close on success) + const nameInput = screen.getByLabelText(/Name/i); + fireEvent.change(nameInput, { target: { value: "Updated name" } }); + const updateBtn = screen.getByRole("button", { name: /update/i }); + fireEvent.click(updateBtn); + + await waitFor(() => { + // Modal closes and card title updates + expect(screen.queryByText("Edit importer")).not.toBeInTheDocument(); + expect(screen.getByText("Updated name")).toBeInTheDocument(); + }); + }); + + test("deletes an importer from edit modal and shows empty state", async () => { + render( + + + , + ); + + // Ensure initial importer is present + expect(screen.getByText("Importer 1")).toBeInTheDocument(); + + // Open edit modal via pencil icon button + const buttons = screen.getAllByRole("button"); + const pencil = buttons.find((b) => b.querySelector("svg")); + if (pencil) fireEvent.click(pencil); + + await waitFor(() => { + expect(screen.getByText("Edit importer")).toBeInTheDocument(); + }); + + // Click delete button inside modal + const deleteBtn = screen.getByTitle("Delete"); + fireEvent.click(deleteBtn); + + // After deletion, modal should close and empty state should appear + await waitFor(() => { + expect(screen.queryByText("Edit importer")).not.toBeInTheDocument(); + expect(screen.queryByText("Importer 1")).not.toBeInTheDocument(); + expect(screen.getByText("No importers found")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/client/dashboard/src/components/transaction/import/importers/user-importers-card.tsx b/apps/client/dashboard/src/components/transaction/import/importers/user-importers-card.tsx new file mode 100644 index 0000000..3b6b254 --- /dev/null +++ b/apps/client/dashboard/src/components/transaction/import/importers/user-importers-card.tsx @@ -0,0 +1,49 @@ +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useProfile } from "@/hooks/use-profile"; +import { Importer } from "./importer-card"; + +export function UserImportersCard() { + const { data } = useProfile(); + + return ( + + + Your importers + + Select one of the available importers to start importing your + transactions. + + + + {data.transactionsImporters.length === 0 ? ( + + ) : ( +
+ {data.transactionsImporters.map((importer) => ( + + ))} +
+ )} +
+
+ ); +} + +function NoImporters() { + return ( + + No importers found + + You have no importers yet. Please create one below to start importing + your transactions. + + + ); +} diff --git a/apps/client/dashboard/src/components/transaction/transaction-form.test.tsx b/apps/client/dashboard/src/components/transaction/transaction-form.test.tsx index 30e3c9d..18ca95c 100644 --- a/apps/client/dashboard/src/components/transaction/transaction-form.test.tsx +++ b/apps/client/dashboard/src/components/transaction/transaction-form.test.tsx @@ -1,17 +1,14 @@ import { TestContextProvider } from "@/utils/tests/contexts"; import { describe, expect, test } from "vitest"; import { fireEvent, render, waitFor } from "@testing-library/react"; -import { - AddTransactionFloatingButton, - TransactionFormModal, -} from "./transaction-form"; +import { AddTransactionButton, TransactionFormModal } from "./transaction-form"; import { Transaction } from "@monyfox/common-data"; import { TransactionsTable } from "./transactions-table"; -test("AddTransactionFloatingButton", async () => { +test("AddTransactionButton", async () => { const { getByRole } = render( - + , ); diff --git a/apps/client/dashboard/src/components/transaction/transaction-form.tsx b/apps/client/dashboard/src/components/transaction/transaction-form.tsx index c08ecc5..9097c3e 100644 --- a/apps/client/dashboard/src/components/transaction/transaction-form.tsx +++ b/apps/client/dashboard/src/components/transaction/transaction-form.tsx @@ -33,16 +33,27 @@ import { getTransactionType, TransactionType } from "@/utils/transaction"; import { getTransactionCategoriesWithChildren } from "@/utils/transaction-category"; import { SelectItemTransactionCategoryWithChildren } from "../settings/transaction-categories/category-select-item"; -export function AddTransactionFloatingButton() { +export function AddTransactionButton({ + isFloating = false, + type, +}: { + isFloating?: boolean; + type: "text" | "icon"; +}) { const { isOpen, openModal, closeModal } = useModal(); + + const buttonClassName = isFloating + ? "fixed bottom-4 right-4 z-10" + : undefined; + + const buttonSize = isFloating ? "lg" : type === "icon" ? "icon" : "default"; + const iconClassName = isFloating ? "size-7" : undefined; + return ( <> - +
+ + + + +
+ + + ); +} diff --git a/apps/client/dashboard/src/components/transaction/transactions-table.tsx b/apps/client/dashboard/src/components/transaction/transactions-table.tsx index ea3f38e..4a72491 100644 --- a/apps/client/dashboard/src/components/transaction/transactions-table.tsx +++ b/apps/client/dashboard/src/components/transaction/transactions-table.tsx @@ -1,224 +1,43 @@ -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { useProfile } from "@/hooks/use-profile"; import { formatCurrency } from "@/utils/currency"; import { getTransactionType, TransactionType } from "@/utils/transaction"; import { type Transaction, type Account } from "@monyfox/common-data"; -import { useMemo, useState } from "react"; -import { - ColumnDef, - ColumnFiltersState, - flexRender, - getCoreRowModel, - getFacetedRowModel, - getFacetedUniqueValues, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - SortingState, - useReactTable, - VisibilityState, -} from "@tanstack/react-table"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Label } from "../ui/label"; +import { useMemo } from "react"; +import { ColumnDef } from "@tanstack/react-table"; import { Button } from "../ui/button"; -import { - ArrowRightIcon, - ChevronLeftIcon, - ChevronRightIcon, - ChevronsLeftIcon, - ChevronsRightIcon, - PencilIcon, - TriangleAlert, -} from "lucide-react"; +import { ArrowRightIcon, PencilIcon, TriangleAlert } from "lucide-react"; import { useModal } from "../ui/modal"; import { TransactionFormModal } from "./transaction-form"; +import { DataTable } from "../data-table"; -export function TransactionsTable() { +type MaybeNonExistentTransaction = Transaction & { nonExistentText?: string }; + +export function TransactionsTable({ + transactions: transactionsOverride, +}: { + transactions?: MaybeNonExistentTransaction[]; +}) { const { data: { transactions: reversedTransactions }, getAccount, getTransactionCategory, } = useProfile(); - const [rowSelection, setRowSelection] = useState({}); - const [columnVisibility, setColumnVisibility] = useState({}); - const [columnFilters, setColumnFilters] = useState([]); - const [sorting, setSorting] = useState([]); - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 10, - }); + const transactions = transactionsOverride ?? reversedTransactions.reverse(); - const transactions = useMemo(() => { - return [...reversedTransactions] - .map((t) => ({ - ...t, - fromAccountName: getAccountName(t.from, getAccount), - toAccountName: getAccountName(t.to, getAccount), - transactionCategoryName: - t.transactionCategoryId === null - ? "" - : getTransactionCategory(t.transactionCategoryId).name, - })) - .reverse(); + const data = useMemo(() => { + return [...transactions].map((t) => ({ + ...t, + fromAccountName: getAccountName(t.from, getAccount), + toAccountName: getAccountName(t.to, getAccount), + transactionCategoryName: + t.transactionCategoryId === null + ? "" + : getTransactionCategory(t.transactionCategoryId).name, + })); }, [getAccount, getTransactionCategory, reversedTransactions]); - const table = useReactTable({ - data: transactions, - columns, - state: { - sorting, - columnVisibility, - rowSelection, - columnFilters, - pagination, - }, - getRowId: (row) => row.id.toString(), - enableRowSelection: true, - onRowSelectionChange: setRowSelection, - onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, - onColumnVisibilityChange: setColumnVisibility, - onPaginationChange: setPagination, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFacetedRowModel: getFacetedRowModel(), - getFacetedUniqueValues: getFacetedUniqueValues(), - }); - - return ( -
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - ); - })} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} - -
-
-
-
- {table.getFilteredSelectedRowModel().rows.length} of{" "} - {table.getFilteredRowModel().rows.length} transactions selected. -
-
-
- - -
-
- Page {table.getState().pagination.pageIndex + 1} of{" "} - {table.getPageCount()} -
-
- - - - -
-
-
-
- ); + return r.id} />; } function AmountText({ transaction }: { transaction: Transaction }) { @@ -247,9 +66,22 @@ function AmountText({ transaction }: { transaction: Transaction }) { ); } -function TransactionActions({ transaction }: { transaction: Transaction }) { +function TransactionActions({ + transaction, +}: { + transaction: MaybeNonExistentTransaction; +}) { const { getAccount } = useProfile(); const { isOpen, openModal, closeModal } = useModal(); + + if (transaction.nonExistentText !== undefined) { + return ( + + {transaction.nonExistentText} + + ); + } + const isUnknown = getTransactionType(transaction, getAccount) === TransactionType.Unknown; return ( diff --git a/apps/client/dashboard/src/components/ui/alert.tsx b/apps/client/dashboard/src/components/ui/alert.tsx index a34fd23..3ade6a9 100644 --- a/apps/client/dashboard/src/components/ui/alert.tsx +++ b/apps/client/dashboard/src/components/ui/alert.tsx @@ -67,12 +67,13 @@ function AlertDescription({ export function DestructiveAlert({ title, children, + ...props }: { title: string; children: React.ReactNode; -}) { +} & React.ComponentProps<"div">) { return ( - + {title} {children} diff --git a/apps/client/dashboard/src/contexts/database-provider.tsx b/apps/client/dashboard/src/contexts/database-provider.tsx index 3e8d2f1..93fae13 100644 --- a/apps/client/dashboard/src/contexts/database-provider.tsx +++ b/apps/client/dashboard/src/contexts/database-provider.tsx @@ -1,28 +1,29 @@ -import { ReactNode } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { Spinner } from "@/components/ui/spinner"; -import { type Profile } from "@monyfox/common-data"; -import { toast } from "sonner"; -import { DatabaseIDBImpl } from "@/database/database-idb"; -import { type ExchangeRateDb, type Database } from "@/database/database"; -import { ErrorPage } from "@/components/error-page"; -import { DatabaseContext } from "./database-context"; -import { notEmpty } from "@/utils/array"; -import { Duration } from "@js-joda/core"; +import {ReactNode} from "react"; +import {useMutation, useQuery} from "@tanstack/react-query"; +import {Spinner} from "@/components/ui/spinner"; +import {type Profile} from "@monyfox/common-data"; +import {toast} from "sonner"; +import {DatabaseIDBImpl} from "@/database/database-idb"; +import {type ExchangeRateDb, type Database} from "@/database/database"; +import {ErrorPage} from "@/components/error-page"; +import {DatabaseContext} from "./database-context"; +import {notEmpty} from "@/utils/array"; +import {Duration} from "@js-joda/core"; const EXCHANGE_RATE_TTL_MS = Duration.ofDays(28).toMillis(); const isExchangeRateExpired = (v: ExchangeRateDb) => Date.now() - Date.parse(v.updatedAt) > EXCHANGE_RATE_TTL_MS; -export const DatabaseProvider = ({ children }: { children: ReactNode }) => { - const dbQuery = useQuery({ queryKey: ["database"], queryFn: getDatabase }); +export const DatabaseProvider = ({children}: { children: ReactNode }) => { + const dbQuery = useQuery({queryKey: ["database"], queryFn: getDatabase}); if (dbQuery.isPending) { - return ; + return ; } if (dbQuery.isError) { - return ; + console.error(dbQuery.error); + return ; } return ( @@ -66,9 +67,9 @@ function DatabaseDataProvider({ queryKey: ["exchange-rates"], queryFn: async () => { const all = await db.exchangeRates.getAll(); - const expiredIds = all.filter(isExchangeRateExpired).map(({ id }) => id); + const expiredIds = all.filter(isExchangeRateExpired).map(({id}) => id); await Promise.all(expiredIds.map((id) => db.exchangeRates.delete(id))); - return all.filter(({ id }) => !expiredIds.includes(id)); + return all.filter(({id}) => !expiredIds.includes(id)); }, }); @@ -77,7 +78,7 @@ function DatabaseDataProvider({ }; if (profilesQuery.isPending || exchangeRatesQuery.isPending) { - return ; + return ; } if (profilesQuery.isError || exchangeRatesQuery.isError) { @@ -86,7 +87,7 @@ function DatabaseDataProvider({ exchangeRatesQuery.error?.message, ].filter(notEmpty); return ( - + ); } @@ -109,7 +110,7 @@ function LoadingPage() { return (
- +
); diff --git a/apps/client/dashboard/src/contexts/profile-context.test.tsx b/apps/client/dashboard/src/contexts/profile-context.test.tsx index 62224ed..c9058e8 100644 --- a/apps/client/dashboard/src/contexts/profile-context.test.tsx +++ b/apps/client/dashboard/src/contexts/profile-context.test.tsx @@ -18,6 +18,17 @@ describe("ProfileProvider", () => { ).toBeDefined(); }); + test("optioanl field has default data", async () => { + const result = render( + + + , + ); + + expect(result.getByText("Accounts:Account 1,Account 2.")).toBeDefined(); + expect(result.getByText("Transactions importers:.")).toBeDefined(); + }); + test("undefined profile", async () => { const result = render( @@ -75,13 +86,17 @@ describe("ProfileProvider", () => { function ProfileDataForTest() { const { - data: { accounts, transactions }, + data: { accounts, transactions, transactionsImporters }, createAccount, } = useProfile(); return (

Accounts:{accounts.map((a) => a.name).join(",")}.

Transactions:{transactions.map((t) => t.description).join(",")}.

+

+ Transactions importers: + {transactionsImporters.map((t) => t.id).join(",")}. +