feat(transactions): serve import dedup and account ledger from the backend (#631)
## Summary
Migrates the last two flows off the deprecated client-side
IndexedDB/Dexie "offline-first" model so they read from the backend
instead of the local database. The Dexie cache was already being
dismantled and is no longer the source of truth for the transaction
list; these two consumers were the last ones still tied to it, and the
local read could silently return incomplete data.
**Duplicate detection on import** and **the account detail transaction
list** now both come from the server.
### What changed
- **Import duplicate detection** → new `POST
/api/transactions/check-duplicates`. Matches on day + amount (integer
cents) + normalized description server-side, replacing
`sync()`-then-read-Dexie, which silently missed duplicates that fell
outside the local cache window.
- **Account detail transaction list** → served as an Inertia prop from
`AccountController@show` (eager-loading `category` + `labels`) instead
of fetching every transaction from `/api/sync/transactions` and
filtering client-side (which could show an incomplete list for accounts
with long history).
- **Import preview "existing transactions" panel** → reads from `GET
/api/transactions?account_id=…&per_page=10` (the index gains an
`account_id` filter + clamped `per_page`) instead of Dexie.
- `TransactionList` now renders only from the provided prop; the
`/api/sync` fetch path, the Dexie-backed search, and the `refreshKey`
refetch are removed (search is in-memory; create triggers a scoped
`router.reload({ only: ['transactions'] })`).
### Commits
1. `feat` — the backend migration (endpoints, controller prop, frontend
rewiring, tests).
2. `refactor` — centralize the "has transaction ledger" rule in
`AccountType::hasTransactionLedger()` (a single named concept the
frontend mirrors, documented as distinct from `isNonTransactional()`),
and add an `id` tiebreak for deterministic pagination.
3. `fix` — drop rows reassigned to another account from the account list
(they lingered now that the list no longer refetches).
4. `fix` — match Unicode whitespace (e.g. non-breaking spaces) in
server-side duplicate detection, matching the old JS behavior; hardens
endpoint tests.
## Review notes
Two review agents (architecture/quality and bugs/correctness) reviewed
the change. Applied: the `AccountType` single-source-of-truth, stable
pagination sort, the account-reassignment stale-row fix, the
Unicode-whitespace parity fix, and extra edge-case tests (validation,
date-range boundary, cross-user scoping).
**Deferred (follow-up):** `AccountController@show` still loads the
account's full transaction history into the Inertia payload. It's
acceptable now (single account, strictly better than the old
load-all-accounts approach) and marked with a `ponytail:` comment, but
accounts with very long history should move to a deferred +
cursor-paginated prop (the new `?account_id=` endpoint can back it).
Also, the deprecated Dexie/sync-manager stack (`sync()`, `getAll`,
`getByAccountId`, `/api/sync/transactions`) now has fewer callers and
can be retired in a dedicated cleanup.
## Test plan
- `tests/Feature/Api/TransactionDuplicateCheckTest.php` — matching,
normalization (incl. NBSP), date-range boundary, account scoping, IDOR,
validation.
- `tests/Feature/Api/TransactionIndexTest.php` — `account_id` filter +
newest-first order, `per_page` cap and lower clamp, cross-user scoping.
- `tests/Feature/AccountControllerTest.php` — the account-show
`transactions` prop (own vs other accounts, non-transactional accounts).
- Existing `DecryptTransactionsTest` (the other `/api/transactions`
consumer) unaffected.
- Frontend vitest suites for the touched areas pass.
Draft while the deferred payload/cleanup items are discussed.
This commit is contained in:
parent
c370abcd6d
commit
021cb66643
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CheckDuplicateTransactionsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<SortingState>([
|
||||
{ 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<string>();
|
||||
|
||||
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<string>();
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<ChartComputedData | null>(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) && (
|
||||
<TransactionList
|
||||
key={transactionRefreshKey}
|
||||
categories={categories}
|
||||
accounts={accounts}
|
||||
banks={banks}
|
||||
labels={labels}
|
||||
automationRules={automationRules}
|
||||
accountId={account.id}
|
||||
transactions={transactions}
|
||||
pageSize={50}
|
||||
hideAccountFilter={true}
|
||||
showActionsMenu={false}
|
||||
|
|
|
|||
|
|
@ -220,48 +220,24 @@ class TransactionSyncService {
|
|||
description: string;
|
||||
}>,
|
||||
): Promise<boolean[]> {
|
||||
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:',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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',
|
||||
]);
|
||||
});
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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([]);
|
||||
});
|
||||
Loading…
Reference in New Issue