diff --git a/app/Enums/AccountType.php b/app/Enums/AccountType.php index 00f177f2..88c5f01f 100644 --- a/app/Enums/AccountType.php +++ b/app/Enums/AccountType.php @@ -34,6 +34,20 @@ enum AccountType: string return in_array($this, [self::Investment, self::Retirement, self::RealEstate], true); } + /** + * Whether this account type surfaces a transaction ledger in the UI. The + * account detail page renders a transaction list only for these types. + * + * Mirrors the frontend `isTransactionalAccount`. This is intentionally + * distinct from isNonTransactional(): that is the narrower balance-only + * concept used for net-worth charts and treats loan accounts as + * balance-tracking-with-amortization, whereas a loan has no editable ledger. + */ + public function hasTransactionLedger(): bool + { + return ! in_array($this, [self::Investment, self::Loan, self::Retirement, self::RealEstate], true); + } + /** * Whether a bank connection can sync transactions into this account type. * Excludes balance/value-tracking types (loan, investment, retirement, real estate). diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 2d975029..0bb56165 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -120,8 +120,20 @@ class AccountController extends Controller } } + // ponytail: load-all transactions for the account (server-side, replaces + // the old client-side sync-all-then-filter). Fine for a single account; + // switch to a deferred/cursor-paginated prop if accounts grow huge. + $transactions = $account->type->hasTransactionLedger() + ? $account->transactions() + ->with(['category', 'labels']) + ->orderBy('transaction_date', 'desc') + ->orderBy('id', 'desc') + ->get() + : []; + return Inertia::render('Accounts/Show', [ 'account' => $data, + 'transactions' => $transactions, ]); } diff --git a/app/Http/Controllers/Api/TransactionController.php b/app/Http/Controllers/Api/TransactionController.php index 0b3d0640..9e598876 100644 --- a/app/Http/Controllers/Api/TransactionController.php +++ b/app/Http/Controllers/Api/TransactionController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Requests\Api\BulkUpdateTransactionRequest; +use App\Http\Requests\Api\CheckDuplicateTransactionsRequest; use App\Models\Transaction; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -23,11 +24,76 @@ class TransactionController extends Controller $query->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv')); } - $transactions = $query->simplePaginate(100); + if ($accountId = $request->query('account_id')) { + $query->where('account_id', $accountId) + ->orderBy('transaction_date', 'desc') + ->orderBy('id', 'desc'); + } + + $perPage = min(max((int) $request->query('per_page', 100), 1), 100); + + $transactions = $query->simplePaginate($perPage); return response()->json($transactions); } + /** + * Flag which of the given (date, amount, description) tuples already exist + * on the account. Replaces the old client-side IndexedDB duplicate check. + * + * Matching mirrors the previous frontend logic: same day, exact amount (both + * in integer cents), and case-insensitive description with collapsed + * whitespace. Returns a boolean per input transaction, in order. + */ + public function checkDuplicates(CheckDuplicateTransactionsRequest $request): JsonResponse + { + $validated = $request->validated(); + $account = $request->user()->accounts()->findOrFail($validated['account_id']); + $incoming = $validated['transactions']; + + $dates = array_map(fn (array $t): string => substr($t['transaction_date'], 0, 10), $incoming); + + $existing = $account->transactions() + ->whereBetween('transaction_date', [min($dates), max($dates)]) + ->get(['transaction_date', 'amount', 'description']); + + $seen = []; + foreach ($existing as $transaction) { + $seen[$this->duplicateKey( + $transaction->transaction_date->format('Y-m-d'), + (int) $transaction->amount, + $transaction->description, + )] = true; + } + + $duplicates = array_map( + fn (array $t): bool => isset($seen[$this->duplicateKey( + substr($t['transaction_date'], 0, 10), + (int) $t['amount'], + $t['description'], + )]), + $incoming, + ); + + return response()->json(['duplicates' => $duplicates]); + } + + private function duplicateKey(string $date, int $amount, string $description): string + { + // Collapse every Unicode whitespace run (matching JS \s, which includes + // the non-breaking spaces common in bank statements) to a single space, + // then trim. PHP's default \s is ASCII-only, so without this an existing + // "Coffee Shop" and an imported "Coffee Shop" would not be seen as + // the same row, unlike the old client-side check. + $normalized = trim((string) preg_replace( + '/[\s\x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]+/u', + ' ', + mb_strtolower($description), + )); + + return $date.'|'.$amount.'|'.$normalized; + } + /** * Bulk update transactions (used for decryption migration). */ diff --git a/app/Http/Requests/Api/CheckDuplicateTransactionsRequest.php b/app/Http/Requests/Api/CheckDuplicateTransactionsRequest.php new file mode 100644 index 00000000..473fe27d --- /dev/null +++ b/app/Http/Requests/Api/CheckDuplicateTransactionsRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'account_id' => ['required', 'uuid'], + 'transactions' => ['required', 'array', 'max:10000'], + 'transactions.*.transaction_date' => ['required', 'date'], + 'transactions.*.amount' => ['required', 'integer'], + 'transactions.*.description' => ['required', 'string'], + ]; + } +} diff --git a/resources/js/components/transactions/import-step-preview.tsx b/resources/js/components/transactions/import-step-preview.tsx index 6f987758..818b0b2f 100644 --- a/resources/js/components/transactions/import-step-preview.tsx +++ b/resources/js/components/transactions/import-step-preview.tsx @@ -17,11 +17,11 @@ import { TableRow, } from '@/components/ui/table'; import { useLocale } from '@/hooks/use-locale'; -import { transactionSyncService } from '@/services/transaction-sync'; import { type ParsedTransaction } from '@/types/import'; import { type Transaction } from '@/types/transaction'; import { formatDateMedium } from '@/utils/date'; import { __ } from '@/utils/i18n'; +import axios from 'axios'; import { ChevronDown } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; @@ -53,16 +53,21 @@ export function ImportStepPreview({ const [isExistingOpen, setIsExistingOpen] = useState(false); useEffect(() => { - if (accountId) { - transactionSyncService.getByAccountId(accountId).then((txns) => { - const sorted = txns.sort( - (a, b) => - new Date(b.transaction_date).getTime() - - new Date(a.transaction_date).getTime(), - ); - setExistingTransactions(sorted.slice(0, 10)); - }); + if (!accountId) { + return; } + + axios + .get('/api/transactions', { + params: { account_id: accountId, per_page: 10 }, + }) + .then((response) => { + setExistingTransactions(response.data.data ?? []); + }) + .catch((error) => { + console.error('Failed to load existing transactions:', error); + setExistingTransactions([]); + }); }, [accountId]); const stats = useMemo(() => { diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx index 44c5d6d2..062e660b 100644 --- a/resources/js/components/transactions/import-transactions-drawer.tsx +++ b/resources/js/components/transactions/import-transactions-drawer.tsx @@ -416,8 +416,6 @@ export function ImportTransactionsDrawer({ return; } - await transactionSyncService.sync(); - const duplicateFlags = await transactionSyncService.checkDuplicates( account.id, parsedTransactions, @@ -720,17 +718,7 @@ export function ImportTransactionsDrawer({ toast.error(__('All transactions failed to import')); } - transactionSyncService - .sync() - .then(() => { - onImportComplete?.(); - }) - .catch((syncError) => { - console.error( - 'Failed to sync transactions with backend:', - syncError, - ); - }); + onImportComplete?.(); }; const handleSelectionChange = (index: number, selected: boolean) => { diff --git a/resources/js/components/transactions/transaction-list.tsx b/resources/js/components/transactions/transaction-list.tsx index ef9f6525..a1a5d088 100644 --- a/resources/js/components/transactions/transaction-list.tsx +++ b/resources/js/components/transactions/transaction-list.tsx @@ -65,7 +65,6 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Spinner } from '@/components/ui/spinner'; import { TableCell, TableRow } from '@/components/ui/table'; import { consoleDebug } from '@/lib/debug'; -import { db } from '@/lib/dexie-db'; import { captureEvent } from '@/lib/posthog'; import { mergeReEvaluatedTransaction } from '@/lib/transaction-re-evaluation'; import { transactionSyncService } from '@/services/transaction-sync'; @@ -231,7 +230,7 @@ export interface TransactionListProps { labels?: Label[]; automationRules?: AutomationRule[]; accountId?: UUID; - transactions?: Transaction[]; // Optional: if provided, use these instead of fetching from Dexie + transactions?: Transaction[]; // Server-provided transactions to render pageSize?: number; hideAccountFilter?: boolean; showActionsMenu?: boolean; @@ -280,7 +279,6 @@ export function TransactionList({ [], ); const [isLoading, setIsLoading] = useState(true); - const [refreshKey, setRefreshKey] = useState(0); const [sorting, setSorting] = useState([ { id: 'transaction_date', desc: true }, ]); @@ -349,122 +347,28 @@ export function TransactionList({ ); useEffect(() => { - async function processTransactions() { - // If transactions are provided directly, use them as-is. - if (providedTransactions) { - setIsLoading(true); - try { - const processed = providedTransactions.map( - (transaction) => - ({ - ...transaction, - decryptedDescription: transaction.description, - decryptedNotes: transaction.notes || null, - label_ids: - transaction.label_ids ?? - transaction.labels?.map( - (label) => label.id, - ) ?? - [], - }) as DecryptedTransaction, - ); - - setTransactions(processed); - } catch (error) { - console.error('Error processing transactions:', error); - } finally { - setIsLoading(false); - } - return; - } - - setIsLoading(true); - try { - const response = await axios.get('/api/sync/transactions'); - const serverData = response.data.data || response.data; - - if (!Array.isArray(serverData)) { - throw new Error('Invalid server response format'); - } - - let filteredServerData = serverData; - if (accountId) { - filteredServerData = serverData.filter( - (t: Transaction) => t.account_id === accountId, - ); - } - - const accountsMap = new Map( - accounts.map((account) => [account.id, account]), - ); - const categoriesMap = new Map( - categories.map((category) => [category.id, category]), - ); - const banksMap = new Map(banks.map((bank) => [bank.id, bank])); - - const transformedTransactions = filteredServerData.map( - (serverRecord: Transaction) => { - const label_ids = serverRecord.labels?.map( - (l: { id: string }) => l.id, - ); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { labels: _labels, ...rest } = serverRecord; - - return { - ...rest, - transaction_date: String( - serverRecord.transaction_date, - ).slice(0, 10), - label_ids: label_ids || [], - } as Transaction; - }, - ); - - const processed = transformedTransactions.map((transaction) => { - const account = accountsMap.get(transaction.account_id); - const category = transaction.category_id - ? categoriesMap.get(transaction.category_id) - : null; - const bank = account?.bank?.id - ? banksMap.get(account.bank!.id) - : undefined; - - return { + setIsLoading(true); + try { + const processed = (providedTransactions ?? []).map( + (transaction) => + ({ ...transaction, decryptedDescription: transaction.description, decryptedNotes: transaction.notes || null, - account, - category: category || null, - bank, - } as DecryptedTransaction; - }); + label_ids: + transaction.label_ids ?? + transaction.labels?.map((label) => label.id) ?? + [], + }) as DecryptedTransaction, + ); - const validTransactions = processed; - - validTransactions.sort((a, b) => { - const dateA = parseISO(a.transaction_date).getTime(); - const dateB = parseISO(b.transaction_date).getTime(); - return dateB - dateA; - }); - - setTransactions(validTransactions); - } catch (error) { - console.error('Failed to load transactions:', error); - } finally { - setIsLoading(false); - } + setTransactions(processed); + } catch (error) { + console.error('Error processing transactions:', error); + } finally { + setIsLoading(false); } - - processTransactions(); - }, [ - refreshKey, - accounts, - banks, - categories, - accountId, - providedTransactions, - ]); + }, [providedTransactions]); useEffect(() => { try { @@ -486,50 +390,35 @@ export function TransactionList({ const [isSearching, setIsSearching] = useState(false); useEffect(() => { - async function searchInIndexedDB() { - if (!filters.searchText) { - setSearchMatchedIds(new Set()); - setIsSearching(false); - return; - } + if (!filters.searchText) { + setSearchMatchedIds(new Set()); + setIsSearching(false); + return; + } - setIsSearching(true); - try { - const searchLower = filters.searchText.toLowerCase(); + setIsSearching(true); + const searchLower = filters.searchText.toLowerCase(); + const matchedIds = new Set(); - let allIndexedTransactions = await db.transactions.toArray(); + for (const tx of transactions) { + const description = ( + tx.decryptedDescription ?? + tx.description ?? + '' + ).toLowerCase(); + const notes = (tx.decryptedNotes ?? tx.notes ?? '').toLowerCase(); - if (accountId) { - allIndexedTransactions = allIndexedTransactions.filter( - (t) => t.account_id === accountId, - ); - } - - const matchedIds = new Set(); - - for (const tx of allIndexedTransactions) { - const matchesDescription = tx.description - .toLowerCase() - .includes(searchLower); - const matchesNotes = - tx.notes?.toLowerCase().includes(searchLower) || false; - - if (matchesDescription || matchesNotes) { - matchedIds.add(tx.id); - } - } - - setSearchMatchedIds(matchedIds); - } catch (error) { - console.error('Failed to search in IndexedDB:', error); - setSearchMatchedIds(new Set()); - } finally { - setIsSearching(false); + if ( + description.includes(searchLower) || + notes.includes(searchLower) + ) { + matchedIds.add(tx.id); } } - searchInIndexedDB(); - }, [filters.searchText, accountId]); + setSearchMatchedIds(matchedIds); + setIsSearching(false); + }, [filters.searchText, transactions]); const manualAccountIds = useMemo( () => @@ -543,6 +432,13 @@ export function TransactionList({ const filteredTransactions = useMemo(() => { return transactions.filter((transaction) => { + // When scoped to a single account, drop rows that were reassigned to + // another account (e.g. via inline edit). The list no longer + // refetches, so without this they would linger until a full reload. + if (accountId && transaction.account_id !== accountId) { + return false; + } + if (filters.searchText && !searchMatchedIds.has(transaction.id)) { return false; } @@ -907,7 +803,6 @@ export function TransactionList({ setDeleteTransaction(null); setIsBulkDeleteMode(false); setRowSelection({}); - setRefreshKey((prev) => prev + 1); } catch (error) { console.error('Failed to delete transaction:', error); } finally { @@ -948,7 +843,6 @@ export function TransactionList({ ); setRowSelection({}); - setRefreshKey((prev) => prev + 1); } catch (error) { console.error('Failed to update transactions:', error); } finally { @@ -987,7 +881,6 @@ export function TransactionList({ ); setRowSelection({}); - setRefreshKey((prev) => prev + 1); } catch (error) { console.error('Failed to update transactions with labels:', error); } finally { @@ -1041,7 +934,6 @@ export function TransactionList({ setDeleteTransaction(null); setIsBulkDeleteMode(false); setRowSelection({}); - setRefreshKey((prev) => prev + 1); } catch (error) { console.error('Failed to delete transactions:', error); } finally { diff --git a/resources/js/pages/Accounts/Show.tsx b/resources/js/pages/Accounts/Show.tsx index 5abebba9..b1f0ed03 100644 --- a/resources/js/pages/Accounts/Show.tsx +++ b/resources/js/pages/Accounts/Show.tsx @@ -60,6 +60,7 @@ import { import { AutomationRule } from '@/types/automation-rule'; import { Category } from '@/types/category'; import { Label as LabelType } from '@/types/label'; +import { type Transaction } from '@/types/transaction'; import { formatDateMedium } from '@/utils/date'; import { __ } from '@/utils/i18n'; import { Head, router } from '@inertiajs/react'; @@ -81,6 +82,7 @@ interface Props { banks: Bank[]; labels?: LabelType[]; automationRules?: AutomationRule[]; + transactions?: Transaction[]; } export default function AccountShow({ @@ -90,6 +92,7 @@ export default function AccountShow({ banks, labels = [], automationRules = [], + transactions = [], }: Props) { const [editOpen, setEditOpen] = useState(false); const [updateBalanceOpen, setUpdateBalanceOpen] = useState(false); @@ -101,7 +104,6 @@ export default function AccountShow({ const [editingLoanDetails, setEditingLoanDetails] = useState(false); const [editLoanDialogOpen, setEditLoanDialogOpen] = useState(false); const [createTransactionOpen, setCreateTransactionOpen] = useState(false); - const [transactionRefreshKey, setTransactionRefreshKey] = useState(0); const [chartComputedData, setChartComputedData] = useState(null); @@ -118,7 +120,7 @@ export default function AccountShow({ } function handleTransactionCreated() { - setTransactionRefreshKey((prev) => prev + 1); + router.reload({ only: ['transactions'] }); handleBalanceUpdated(); } @@ -381,13 +383,13 @@ export default function AccountShow({ {isTransactionalAccount(account) && ( , ): Promise { + if (transactions.length === 0) { + return []; + } + try { - if (transactions.length === 0) { - return []; - } + const response = await axios.post<{ duplicates: boolean[] }>( + '/api/transactions/check-duplicates', + { + account_id: accountId, + transactions: transactions.map((t) => ({ + transaction_date: t.transaction_date, + amount: t.amount, + description: t.description, + })), + }, + ); - const dates = transactions.map((t) => t.transaction_date); - const minDate = dates.reduce((a, b) => (a < b ? a : b)); - const maxDate = dates.reduce((a, b) => (a > b ? a : b)); - - const normalizeDate = (dateStr: string): string => - dateStr.slice(0, 10); - - const allTransactions = await this.getByAccountId(accountId); - const transactionsInRange = allTransactions.filter((t) => { - const txDate = normalizeDate(t.transaction_date); - return txDate >= minDate && txDate <= maxDate; - }); - - const existingTransactions = transactionsInRange.map((t) => ({ - transaction_date: normalizeDate(t.transaction_date), - amount: parseFloat(t.amount), - description: t.description - .toLowerCase() - .trim() - .replace(/\s+/g, ' '), - })); - - return transactions.map((importingTx) => { - const normalizedDescription = importingTx.description - .toLowerCase() - .trim() - .replace(/\s+/g, ' '); - - return existingTransactions.some( - (existing) => - existing.transaction_date === - importingTx.transaction_date && - Math.abs(existing.amount - importingTx.amount) < - 0.001 && - existing.description === normalizedDescription, - ); - }); + return response.data.duplicates; } catch (error) { console.warn( 'Duplicate check failed, assuming no duplicates:', diff --git a/routes/api.php b/routes/api.php index 5f9bca04..f26cbb74 100644 --- a/routes/api.php +++ b/routes/api.php @@ -27,6 +27,7 @@ Route::middleware(['web', 'auth'])->group(function () { // Transactions Route::get('transactions', [TransactionController::class, 'index'])->name('api.transactions.index'); + Route::post('transactions/check-duplicates', [TransactionController::class, 'checkDuplicates'])->name('api.transactions.check-duplicates'); Route::get('transactions/analysis', [TransactionAnalysisController::class, 'summary'])->name('api.transactions.analysis'); Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('api.transactions.bulk-update'); diff --git a/tests/Feature/AccountControllerTest.php b/tests/Feature/AccountControllerTest.php index af5b1a69..68e5db3f 100644 --- a/tests/Feature/AccountControllerTest.php +++ b/tests/Feature/AccountControllerTest.php @@ -8,6 +8,7 @@ use App\Models\Bank; use App\Models\Category; use App\Models\Label; use App\Models\RealEstateDetail; +use App\Models\Transaction; use App\Models\User; beforeEach(function () { @@ -212,6 +213,53 @@ test('account show includes categories, accounts, and banks', function () { ); }); +test('account show includes the account transactions and excludes other accounts', function () { + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Checking, + ]); + + $otherAccount = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Checking, + ]); + + Transaction::factory()->count(2)->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + ]); + + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $otherAccount->id, + ]); + + $this->get(route('accounts.show', $account)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Accounts/Show') + ->has('transactions', 2) + ); +}); + +test('account show returns no transactions for non-transactional accounts', function () { + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Investment, + ]); + + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + ]); + + $this->get(route('accounts.show', $account)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->has('transactions', 0) + ); +}); + test('account show includes shared labels and automation rules for transaction tools', function () { $account = Account::factory()->create([ 'user_id' => $this->user->id, diff --git a/tests/Feature/Api/TransactionDuplicateCheckTest.php b/tests/Feature/Api/TransactionDuplicateCheckTest.php new file mode 100644 index 00000000..d383027c --- /dev/null +++ b/tests/Feature/Api/TransactionDuplicateCheckTest.php @@ -0,0 +1,141 @@ +user = User::factory()->create(); + $this->account = Account::factory()->create(['user_id' => $this->user->id]); + actingAs($this->user); +}); + +it('flags transactions that already exist on the account', function () { + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'transaction_date' => '2026-01-15', + 'amount' => 1234, + 'description' => 'Coffee Shop', + ]); + + $response = $this->postJson('/api/transactions/check-duplicates', [ + 'account_id' => $this->account->id, + 'transactions' => [ + ['transaction_date' => '2026-01-15', 'amount' => 1234, 'description' => 'Coffee Shop'], + ['transaction_date' => '2026-01-15', 'amount' => 1235, 'description' => 'Coffee Shop'], + ], + ]); + + $response->assertOk()->assertJson(['duplicates' => [true, false]]); +}); + +it('normalizes case and whitespace when matching descriptions', function () { + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'transaction_date' => '2026-01-15', + 'amount' => 1234, + 'description' => 'Coffee Shop', + ]); + + $response = $this->postJson('/api/transactions/check-duplicates', [ + 'account_id' => $this->account->id, + 'transactions' => [ + ['transaction_date' => '2026-01-15', 'amount' => 1234, 'description' => " cOFFee shOP \n"], + ], + ]); + + $response->assertOk()->assertJson(['duplicates' => [true]]); +}); + +it('scopes duplicate detection to the given account', function () { + $otherAccount = Account::factory()->create(['user_id' => $this->user->id]); + + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $otherAccount->id, + 'transaction_date' => '2026-01-15', + 'amount' => 1234, + 'description' => 'Coffee Shop', + ]); + + $response = $this->postJson('/api/transactions/check-duplicates', [ + 'account_id' => $this->account->id, + 'transactions' => [ + ['transaction_date' => '2026-01-15', 'amount' => 1234, 'description' => 'Coffee Shop'], + ], + ]); + + $response->assertOk()->assertJson(['duplicates' => [false]]); +}); + +it('does not leak another users account', function () { + $stranger = User::factory()->create(); + $strangerAccount = Account::factory()->create(['user_id' => $stranger->id]); + + $response = $this->postJson('/api/transactions/check-duplicates', [ + 'account_id' => $strangerAccount->id, + 'transactions' => [ + ['transaction_date' => '2026-01-15', 'amount' => 1234, 'description' => 'Coffee Shop'], + ], + ]); + + $response->assertNotFound(); +}); + +it('treats non-breaking spaces as regular whitespace when matching', function () { + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'transaction_date' => '2026-01-15', + 'amount' => 1234, + 'description' => 'Coffee Shop', + ]); + + $response = $this->postJson('/api/transactions/check-duplicates', [ + 'account_id' => $this->account->id, + 'transactions' => [ + ['transaction_date' => '2026-01-15', 'amount' => 1234, 'description' => "Coffee\u{00A0}Shop"], + ], + ]); + + $response->assertOk()->assertJson(['duplicates' => [true]]); +}); + +it('only matches existing transactions within the incoming date range', function () { + // Same amount + description as the incoming row, but outside its date range. + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'transaction_date' => '2025-12-01', + 'amount' => 1234, + 'description' => 'Coffee Shop', + ]); + + $response = $this->postJson('/api/transactions/check-duplicates', [ + 'account_id' => $this->account->id, + 'transactions' => [ + ['transaction_date' => '2026-01-15', 'amount' => 1234, 'description' => 'Coffee Shop'], + ], + ]); + + $response->assertOk()->assertJson(['duplicates' => [false]]); +}); + +it('validates the request payload', function () { + $response = $this->postJson('/api/transactions/check-duplicates', [ + 'transactions' => [ + ['transaction_date' => 'not-a-date', 'amount' => 'abc', 'description' => ''], + ], + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors([ + 'account_id', + 'transactions.0.transaction_date', + 'transactions.0.amount', + 'transactions.0.description', + ]); +}); diff --git a/tests/Feature/Api/TransactionIndexTest.php b/tests/Feature/Api/TransactionIndexTest.php new file mode 100644 index 00000000..8eb692ad --- /dev/null +++ b/tests/Feature/Api/TransactionIndexTest.php @@ -0,0 +1,75 @@ +user = User::factory()->create(); + actingAs($this->user); +}); + +it('filters transactions by account, newest first', function () { + $account = Account::factory()->create(['user_id' => $this->user->id]); + $otherAccount = Account::factory()->create(['user_id' => $this->user->id]); + + $older = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'transaction_date' => '2026-01-01', + ]); + $newer = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'transaction_date' => '2026-02-01', + ]); + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $otherAccount->id, + ]); + + $response = $this->getJson('/api/transactions?account_id='.$account->id); + + $response->assertOk(); + + $ids = collect($response->json('data'))->pluck('id')->all(); + expect($ids)->toBe([$newer->id, $older->id]); +}); + +it('caps the page size at 100', function () { + $account = Account::factory()->create(['user_id' => $this->user->id]); + + Transaction::factory()->count(3)->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + ]); + + $response = $this->getJson('/api/transactions?account_id='.$account->id.'&per_page=999999'); + + $response->assertOk()->assertJsonPath('per_page', 100); +}); + +it('clamps a non-positive page size up to 1', function () { + $account = Account::factory()->create(['user_id' => $this->user->id]); + + $response = $this->getJson('/api/transactions?account_id='.$account->id.'&per_page=0'); + + $response->assertOk()->assertJsonPath('per_page', 1); +}); + +it('does not return transactions from another users account', function () { + $stranger = User::factory()->create(); + $strangerAccount = Account::factory()->create(['user_id' => $stranger->id]); + + Transaction::factory()->count(2)->create([ + 'user_id' => $stranger->id, + 'account_id' => $strangerAccount->id, + ]); + + $response = $this->getJson('/api/transactions?account_id='.$strangerAccount->id); + + $response->assertOk(); + expect($response->json('data'))->toBe([]); +});