chore: Simplify IndexedDB sync by moving to Inertia shared props (#63)

This PR simplifies the IndexedDB synchronization mechanism by removing
individual sync controllers and services, and instead using Inertia.js
shared props to provide data globally across the application.

## Benefits

1. **Simplified Architecture**: Removed complex sync logic for
static/semi-static data (accounts, categories, banks, labels, automation
rules)
2. **Better Performance**: Data is now shared via Inertia props,
eliminating unnecessary API calls and IndexedDB operations
3. **Reduced Complexity**: Significantly reduced codebase size (~2000
lines removed)
4. **Better UX**: Data is immediately available on page load without
waiting for sync operations
5. **Maintainability**: Fewer moving parts means easier to maintain and
debug

## Migration Notes

- Transaction syncing still uses IndexedDB for offline support
- All other data (accounts, categories, banks, labels, automation rules)
is now fetched via Inertia shared props
- Components automatically receive updated data on navigation without
manual sync operations
This commit is contained in:
Víctor Falcón 2026-01-19 19:15:26 +01:00 committed by GitHub
parent 49ed94cbc7
commit 439ec86722
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 1083 additions and 2933 deletions

View File

@ -1,4 +1,4 @@
APP_NAME=Laravel
APP_NAME="Whisper Money"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
@ -42,7 +42,6 @@ CACHE_STORE=database
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379

View File

@ -15,12 +15,20 @@ RUN apt-get update && apt-get install -y \
netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip intl
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip intl sockets
RUN pecl install redis \
&& docker-php-ext-enable redis
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Install Bun
RUN curl -fsSL https://bun.com/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
WORKDIR /app
# Install Playwright browsers for Pest browser tests
# Note: This requires node_modules to be installed first, so it's done at runtime
# or you can copy package files and run: bun install && bunx playwright install --with-deps chromium
CMD ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8000"]

View File

@ -26,6 +26,33 @@ class AccountBalanceController extends Controller
return response()->json($balances);
}
/**
* Store a new balance for an account.
*/
public function store(Account $account): JsonResponse
{
$this->authorize('update', $account);
$validated = request()->validate([
'balance' => 'required|numeric',
'balance_date' => 'required|date',
]);
$balance = AccountBalance::updateOrCreate(
[
'account_id' => $account->id,
'balance_date' => $validated['balance_date'],
],
[
'balance' => $validated['balance'],
]
);
return response()->json([
'data' => $balance,
], 201);
}
/**
* Update or create the current balance for an account.
*/

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class ImportDataController extends Controller
{
/**
* Get data needed for import operations.
*/
public function index(): JsonResponse
{
$user = auth()->user();
return response()->json([
'accounts' => $user->accounts()
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']),
'categories' => $user->categories()
->orderBy('name')
->get(['id', 'name', 'icon', 'color']),
'banks' => $user->banks()
->orderBy('name')
->get(['id', 'name', 'logo']),
'automationRules' => $user->automationRules()
->with('category:id,name,icon,color')
->orderBy('priority')
->get(),
]);
}
}

View File

@ -27,7 +27,7 @@ class AutomationRuleController extends Controller
->get(['id', 'title', 'priority', 'rules_json', 'action_category_id', 'action_note', 'action_note_iv']);
return Inertia::render('settings/automation-rules', [
'rules' => $rules,
'automationRules' => $rules,
]);
}

View File

@ -6,10 +6,29 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\StoreBankRequest;
use App\Models\Bank;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class BankController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Bank::query()
->where(function ($q) {
$q->whereNull('user_id')
->orWhere('user_id', auth()->id());
});
if ($request->has('search')) {
$search = $request->input('search');
$query->where('name', 'like', "%{$search}%");
}
$banks = $query->orderBy('name')->limit(20)->get();
return response()->json(['data' => $banks]);
}
public function store(StoreBankRequest $request): JsonResponse
{
$data = [

View File

@ -1,84 +0,0 @@
<?php
namespace App\Http\Controllers\Sync;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreAccountBalanceRequest;
use App\Models\AccountBalance;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AccountBalanceSyncController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = AccountBalance::query()
->whereHas('account', function ($q) use ($request) {
$q->where('user_id', $request->user()->id);
});
if ($request->has('since')) {
$query->where('updated_at', '>', $request->input('since'));
}
$balances = $query
->orderBy('balance_date', 'desc')
->orderBy('updated_at', 'desc')
->get();
return response()->json([
'data' => $balances,
]);
}
public function store(StoreAccountBalanceRequest $request): JsonResponse
{
$data = $request->validated();
$existing = AccountBalance::where('account_id', $data['account_id'])
->where('balance_date', $data['balance_date'])
->first();
if ($existing) {
$existing->update(['balance' => $data['balance']]);
$balance = $existing;
$wasRecentlyCreated = false;
} else {
$balance = new AccountBalance([
'account_id' => $data['account_id'],
'balance_date' => $data['balance_date'],
'balance' => $data['balance'],
]);
if (isset($data['id'])) {
$balance->id = $data['id'];
$balance->exists = false;
}
$balance->save();
$wasRecentlyCreated = true;
}
return response()->json([
'data' => $balance->fresh(),
], $wasRecentlyCreated ? 201 : 200);
}
public function update(StoreAccountBalanceRequest $request, AccountBalance $accountBalance): JsonResponse
{
$accountBalance->account->loadMissing('user');
if ($accountBalance->account->user_id !== $request->user()->id) {
abort(403, 'Unauthorized');
}
$data = $request->validated();
unset($data['id']);
$accountBalance->update($data);
return response()->json([
'data' => $accountBalance->fresh(),
]);
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Http\Controllers\Sync;
use App\Http\Controllers\Controller;
use App\Models\Account;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AccountSyncController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Account::query()
->with('bank')
->where('user_id', $request->user()->id);
if ($request->has('since')) {
$query->where('updated_at', '>', $request->input('since'));
}
$accounts = $query->orderBy('updated_at', 'asc')->get();
return response()->json([
'data' => $accounts,
]);
}
}

View File

@ -1,23 +0,0 @@
<?php
namespace App\Http\Controllers\Sync;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class AutomationRuleSyncController extends Controller
{
/**
* Get all automation rules for the authenticated user.
*/
public function index(): JsonResponse
{
$rules = auth()->user()
->automationRules()
->with(['category:id,name,icon,color', 'labels:id,name,color'])
->orderBy('priority')
->get();
return response()->json($rules);
}
}

View File

@ -1,29 +0,0 @@
<?php
namespace App\Http\Controllers\Sync;
use App\Http\Controllers\Controller;
use App\Models\Bank;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BankSyncController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Bank::query()->where(function ($q) use ($request) {
$q->whereNull('user_id')
->orWhere('user_id', $request->user()->id);
});
if ($request->has('since')) {
$query->where('updated_at', '>', $request->input('since'));
}
$banks = $query->orderBy('updated_at', 'asc')->get();
return response()->json([
'data' => $banks,
]);
}
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Http\Controllers\Sync;
use App\Http\Controllers\Controller;
use App\Models\Category;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CategorySyncController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Category::query()->where('user_id', $request->user()->id);
if ($request->has('since')) {
$query->where('updated_at', '>', $request->input('since'));
}
$categories = $query->orderBy('name', 'asc')->get();
return response()->json([
'data' => $categories,
]);
}
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Http\Controllers\Sync;
use App\Http\Controllers\Controller;
use App\Models\Label;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class LabelSyncController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Label::query()->where('user_id', $request->user()->id);
if ($request->has('since')) {
$query->where('updated_at', '>', $request->input('since'));
}
$labels = $query->orderBy('name', 'asc')->get();
return response()->json([
'data' => $labels,
]);
}
}

View File

@ -6,6 +6,7 @@ use App\Http\Requests\BulkUpdateTransactionsRequest;
use App\Http\Requests\StoreTransactionRequest;
use App\Http\Requests\UpdateTransactionRequest;
use App\Models\Account;
use App\Models\AutomationRule;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Label;
@ -48,11 +49,17 @@ class TransactionController extends Controller
->orderBy('name')
->get(['id', 'name', 'color']);
$automationRules = AutomationRule::query()
->where('user_id', $user->id)
->orderBy('priority')
->get();
return Inertia::render('transactions/index', [
'categories' => $categories,
'accounts' => $accounts,
'banks' => $banks,
'labels' => $labels,
'automationRules' => $automationRules,
]);
}

View File

@ -69,6 +69,23 @@ class HandleInertiaRequests extends Middleware
'features' => [
'cashflow' => true,
],
'accounts' => fn () => $user ? $user->accounts()
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']) : [],
'categories' => fn () => $user ? $user->categories()
->orderBy('name')
->get(['id', 'name', 'icon', 'color']) : [],
'banks' => fn () => $user ? $user->banks()
->orderBy('name')
->get(['id', 'name', 'logo']) : [],
'automationRules' => fn () => $user ? $user->automationRules()
->with('category:id,name,icon,color')
->orderBy('priority')
->get() : [],
'labels' => fn () => $user ? $user->labels()
->orderBy('name')
->get(['id', 'name', 'color']) : [],
];
}
}

View File

@ -4,6 +4,7 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\DripEmailType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
@ -84,6 +85,15 @@ class User extends Authenticatable
return $this->hasMany(Category::class);
}
public function banks(): HasMany
{
return $this->hasMany(Bank::class)
->where(function (Builder $query) {
$query->whereNull('user_id')
->orWhere('banks.user_id', $this->id);
});
}
public function automationRules(): HasMany
{
return $this->hasMany(AutomationRule::class);

View File

@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"dependencies": {

View File

@ -790,6 +790,129 @@ stop_services() {
echo -e "${GREEN}Services stopped.${NC}"
}
# Development mode - enable/disable
dev_mode() {
local action="${1:-toggle}"
case "$action" in
on | start | enable)
echo -e "${BLUE}Enabling development mode...${NC}"
echo ""
# Update .env to enable DEV_MODE
if grep -q "^DEV_MODE=" .env; then
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' 's/^DEV_MODE=.*/DEV_MODE=true/' .env
else
sed -i 's/^DEV_MODE=.*/DEV_MODE=true/' .env
fi
else
echo "DEV_MODE=true" >>.env
fi
# Restart PHP container to pick up the change
echo -e "${YELLOW}Restarting PHP container...${NC}"
docker compose restart php
# Wait for it to be ready
sleep 3
wait_for_service "php"
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN} ✓ Development mode enabled!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "${BLUE}PHP container is now running:${NC}"
echo -e "${GREEN}php artisan serve${NC} - Development server"
echo -e "${GREEN}php artisan queue:listen${NC} - Queue worker"
echo -e "${GREEN}php artisan pail${NC} - Log viewer"
echo ""
echo -e "${YELLOW}To start Vite for hot-reload, run in a separate terminal:${NC}"
echo -e " ${GREEN}bun run dev${NC}"
echo ""
echo -e "${BLUE}View PHP logs:${NC}"
echo -e " ${YELLOW}whispermoney logs${NC}"
echo ""
echo -e "${BLUE}To disable development mode:${NC}"
echo -e " ${YELLOW}whispermoney dev off${NC}"
echo ""
;;
off | stop | disable)
echo -e "${BLUE}Disabling development mode...${NC}"
echo ""
# Update .env to disable DEV_MODE
if grep -q "^DEV_MODE=" .env; then
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' 's/^DEV_MODE=.*/DEV_MODE=false/' .env
else
sed -i 's/^DEV_MODE=.*/DEV_MODE=false/' .env
fi
fi
# Restart PHP container
echo -e "${YELLOW}Restarting PHP container...${NC}"
docker compose restart php
# Wait for it to be ready
sleep 3
wait_for_service "php"
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN} ✓ Development mode disabled!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "${BLUE}PHP container is now running in production mode.${NC}"
echo ""
;;
status)
if [ ! -f .env ]; then
echo -e "${RED}.env file not found${NC}"
exit 1
fi
if grep -q "^DEV_MODE=true" .env 2>/dev/null; then
echo -e "${GREEN}Development mode: ${GREEN}ENABLED${NC}"
echo ""
echo -e "${BLUE}Running processes in PHP container:${NC}"
echo -e " • php artisan serve"
echo -e " • php artisan queue:listen"
echo -e " • php artisan pail"
else
echo -e "${BLUE}Development mode: ${YELLOW}DISABLED${NC}"
echo ""
echo -e "${BLUE}To enable:${NC}"
echo -e " ${YELLOW}whispermoney dev on${NC}"
fi
echo ""
;;
*)
echo -e "${RED}Invalid dev mode action: $action${NC}"
echo ""
echo -e "${YELLOW}Usage:${NC}"
echo -e " ${GREEN}whispermoney dev on${NC} - Enable development mode"
echo -e " ${GREEN}whispermoney dev off${NC} - Disable development mode"
echo -e " ${GREEN}whispermoney dev status${NC} - Check development mode status"
echo ""
exit 1
;;
esac
}
# Show logs
show_logs() {
local service="${1:-php}"
echo -e "${BLUE}Showing logs for ${service} (Ctrl+C to exit)...${NC}"
echo ""
docker compose logs -f "$service"
}
# Install function
install() {
print_header
@ -1237,19 +1360,34 @@ main() {
upgrade)
upgrade
;;
dev)
dev_mode "${2:-on}"
;;
logs)
show_logs "${2:-php}"
;;
"")
ask_for_action
;;
*)
echo -e "${RED}Unknown command: $1${NC}"
echo ""
echo "Usage: $0 [install|start|stop|upgrade]"
echo "Usage: $0 [install|start|stop|upgrade|dev|logs]"
echo ""
echo "Commands:"
echo " install - Install Whisper Money"
echo " start - Start Docker services"
echo " stop - Stop Docker services"
echo " upgrade - Upgrade Whisper Money"
echo " dev [on|off] - Enable/disable development mode"
echo " logs [service] - Show logs (default: php)"
echo ""
echo "Examples:"
echo " whispermoney dev on - Enable dev mode (queue, pail)"
echo " whispermoney dev off - Disable dev mode"
echo " whispermoney dev status - Check dev mode status"
echo " whispermoney logs php - Show PHP logs"
echo " whispermoney logs mysql - Show MySQL logs"
echo ""
echo "If no command is provided, an interactive menu will be shown."
exit 1

View File

@ -1,8 +1,8 @@
import {
destroy,
index,
store,
} from '@/actions/App/Http/Controllers/AccountBalanceController';
import { store } from '@/actions/App/Http/Controllers/Sync/AccountBalanceSyncController';
import {
AlertDialog,
AlertDialogAction,
@ -33,7 +33,6 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { accountBalanceSyncService } from '@/services/account-balance-sync';
import type { Account, AccountBalance } from '@/types/account';
import { Pencil, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
@ -135,7 +134,7 @@ export function BalancesModal({
setIsEditSubmitting(true);
try {
const response = await fetch(store.url(), {
const response = await fetch(store.url(account.id), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -148,7 +147,6 @@ export function BalancesModal({
Accept: 'application/json',
},
body: JSON.stringify({
account_id: account.id,
balance_date: editDate,
balance: editAmount,
}),
@ -158,8 +156,6 @@ export function BalancesModal({
throw new Error('Failed to update balance');
}
await accountBalanceSyncService.sync();
setEditingBalance(null);
fetchBalances(currentPage);
onBalanceChange?.();
@ -198,8 +194,6 @@ export function BalancesModal({
throw new Error('Failed to delete balance');
}
await accountBalanceSyncService.sync();
setDeletingBalance(null);
fetchBalances(currentPage);
onBalanceChange?.();

View File

@ -1,3 +1,4 @@
import { index as indexBanks } from '@/actions/App/Http/Controllers/Settings/BankController';
import { Button } from '@/components/ui/button';
import {
Command,
@ -14,7 +15,6 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { bankSyncService } from '@/services/bank-sync';
import { type Bank } from '@/types/account';
import { Check, ChevronsUpDown, Plus } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
@ -58,7 +58,19 @@ export function BankCombobox({
setIsLoading(true);
try {
const results = await bankSyncService.search(query);
const response = await fetch(
indexBanks.url({ query: { search: query } }),
{
headers: {
Accept: 'application/json',
},
},
);
if (!response.ok) {
throw new Error('Failed to search banks');
}
const data = await response.json();
const results = data.data || data;
bankCache.set(query, results);
setBanks(results);
} catch (error) {

View File

@ -206,7 +206,11 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting} data-testid="submit-account">
<Button
type="submit"
disabled={isSubmitting}
data-testid="submit-account"
>
{isSubmitting ? 'Creating...' : 'Create'}
</Button>
</div>

View File

@ -10,7 +10,6 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { db } from '@/lib/dexie-db';
import { type Account } from '@/types/account';
import { Form, router } from '@inertiajs/react';
import { useState } from 'react';
@ -73,8 +72,7 @@ export function DeleteAccountDialog({
<Form
{...destroy.form.delete(account.id)}
onSuccess={async () => {
await db.accounts.delete(account.id);
onSuccess={() => {
handleOpenChange(false);
if (redirectTo) {
router.visit(redirectTo);

View File

@ -1,3 +1,4 @@
import { store } from '@/actions/App/Http/Controllers/AccountBalanceController';
import AlertError from '@/components/alert-error';
import {
Drawer,
@ -17,8 +18,6 @@ import {
parseDate,
parseFile,
} from '@/lib/file-parser';
import { accountBalanceSyncService } from '@/services/account-balance-sync';
import { accountSyncService } from '@/services/account-sync';
import { type Account } from '@/types/account';
import {
BalanceImportStep,
@ -39,6 +38,7 @@ import { ImportBalanceStepUpload } from './import-balances/import-balance-step-u
interface ImportBalancesDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
accounts?: Account[];
accountId?: UUID;
onSuccess?: () => void;
}
@ -55,6 +55,7 @@ interface ImportError {
export function ImportBalancesDrawer({
open,
onOpenChange,
accounts = [],
accountId,
onSuccess,
}: ImportBalancesDrawerProps) {
@ -89,15 +90,14 @@ export function ImportBalancesDrawer({
useEffect(() => {
if (state.selectedAccountId) {
accountSyncService
.getById(state.selectedAccountId)
.then((account) => {
const account = accounts.find(
(a) => a.id === state.selectedAccountId,
);
if (account) {
setSelectedAccount(account);
}
});
}
}, [state.selectedAccountId]);
}, [state.selectedAccountId, accounts]);
useEffect(() => {
if (!open) {
@ -381,6 +381,13 @@ export function ImportBalancesDrawer({
const BATCH_SIZE = 50;
let processedCount = 0;
const xsrfToken = decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
for (let i = 0; i < state.balances.length; i += BATCH_SIZE) {
const batch = state.balances.slice(i, i + BATCH_SIZE);
@ -388,16 +395,33 @@ export function ImportBalancesDrawer({
batch.map(async (balance, batchIndex) => {
const rowNumber = i + batchIndex + 1;
const createdBalance =
await accountBalanceSyncService.updateOrCreate(
selectedAccount.id,
balance.balance_date,
balance.balance,
const response = await fetch(
store.url(selectedAccount.id),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': xsrfToken,
Accept: 'application/json',
},
body: JSON.stringify({
balance_date: balance.balance_date,
balance: balance.balance,
}),
},
);
if (!response.ok) {
const data = await response.json();
throw new Error(
data.message || 'Failed to create balance',
);
}
const data = await response.json();
return {
success: true,
balance: createdBalance,
balance: data.data,
rowNumber,
};
}),
@ -462,10 +486,6 @@ export function ImportBalancesDrawer({
} else {
toast.error('All balances failed to import');
}
accountBalanceSyncService.sync().catch((syncError) => {
console.error('Failed to sync balances with backend:', syncError);
});
};
const moveToStep = (step: BalanceImportStep) => {

View File

@ -2,51 +2,23 @@ import { EncryptedText } from '@/components/encrypted-text';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { accountSyncService } from '@/services/account-sync';
import { type Account } from '@/types/account';
import type { UUID } from '@/types/uuid';
import { Building2 } from 'lucide-react';
import { useEffect, useState } from 'react';
interface ImportBalanceStepAccountProps {
accounts?: Account[];
selectedAccountId: UUID | null;
onAccountSelect: (accountId: UUID) => void;
onNext: () => void;
}
export function ImportBalanceStepAccount({
accounts = [],
selectedAccountId,
onAccountSelect,
onNext,
}: ImportBalanceStepAccountProps) {
const [accounts, setAccounts] = useState<Account[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadAccounts = async () => {
try {
const data = await accountSyncService.getAll();
setAccounts(data);
} catch (error) {
console.error('Failed to load accounts:', error);
} finally {
setLoading(false);
}
};
loadAccounts();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<p className="text-sm text-muted-foreground">
Loading accounts...
</p>
</div>
);
}
if (accounts.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 text-center">

View File

@ -1,4 +1,4 @@
import { store } from '@/actions/App/Http/Controllers/Sync/AccountBalanceSyncController';
import { store } from '@/actions/App/Http/Controllers/AccountBalanceController';
import { AmountInput } from '@/components/ui/amount-input';
import { Button } from '@/components/ui/button';
import {
@ -11,7 +11,6 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { accountBalanceSyncService } from '@/services/account-balance-sync';
import type { Account } from '@/types/account';
import { useState } from 'react';
@ -53,7 +52,7 @@ export function UpdateBalanceDialog({
setError(null);
try {
const response = await fetch(store.url(), {
const response = await fetch(store.url(account.id), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -66,7 +65,6 @@ export function UpdateBalanceDialog({
Accept: 'application/json',
},
body: JSON.stringify({
account_id: account.id,
balance_date: date,
balance: balance,
}),
@ -77,8 +75,6 @@ export function UpdateBalanceDialog({
throw new Error(data.message || 'Failed to update balance');
}
await accountBalanceSyncService.sync();
handleOpenChange(false);
onSuccess?.();
} catch (err) {

View File

@ -1,6 +1,5 @@
import { Breadcrumbs } from '@/components/breadcrumbs';
import { EncryptionKeyButton } from '@/components/encryption-key-button';
import { SyncStatusButton } from '@/components/sync-status-button';
import { ImportTransactionsButton } from '@/components/transactions/import-transactions-button';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
@ -28,7 +27,6 @@ export function AppSidebarHeader({
orientation="vertical"
className="data-[orientation=vertical]:h-6"
/>
<SyncStatusButton />
<EncryptionKeyButton />
<Separator
orientation="vertical"

View File

@ -1,3 +1,4 @@
import { usePage } from '@inertiajs/react';
import {
Cell,
ColumnDef,
@ -11,7 +12,6 @@ import {
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import { useLiveQuery } from 'dexie-react-hooks';
import * as Icons from 'lucide-react';
import { MoreHorizontal } from 'lucide-react';
import { useMemo, useState } from 'react';
@ -53,16 +53,24 @@ import {
TableRow,
} from '@/components/ui/table';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { db } from '@/lib/dexie-db';
import { type AutomationRule, getRuleActions } from '@/types/automation-rule';
import { getCategoryColorClasses } from '@/types/category';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { type Label } from '@/types/label';
interface AutomationRulesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
function AutomationRuleActions({
rule,
categories,
labels,
}: {
rule: AutomationRule;
categories: Category[];
labels: Label[];
}) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@ -70,7 +78,11 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0" aria-label="Actions">
<Button
variant="ghost"
className="h-8 w-8 p-0"
aria-label="Actions"
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
@ -91,6 +103,8 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
<EditAutomationRuleDialog
rule={rule}
categories={categories}
labels={labels}
open={editOpen}
onOpenChange={setEditOpen}
/>
@ -103,7 +117,15 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
);
}
function AutomationRuleRow({ row }: { row: Row<AutomationRule> }) {
function AutomationRuleRow({
row,
categories,
labels,
}: {
row: Row<AutomationRule>;
categories: Category[];
labels: Label[];
}) {
const rule = row.original;
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@ -147,6 +169,8 @@ function AutomationRuleRow({ row }: { row: Row<AutomationRule> }) {
<EditAutomationRuleDialog
rule={rule}
categories={categories}
labels={labels}
open={editOpen}
onOpenChange={setEditOpen}
/>
@ -164,8 +188,13 @@ export function AutomationRulesDialog({
onOpenChange,
}: AutomationRulesDialogProps) {
const { isKeySet } = useEncryptionKey();
const rawRules =
useLiveQuery(() => db.automation_rules.toArray(), []) || [];
const { automationRules: rawRules } = usePage<{
automationRules: AutomationRule[];
}>().props;
// Get categories and labels from globally shared Inertia data
const categories = usePage().props.categories as Category[];
const labels = usePage().props.labels as Label[];
const rules = useMemo(
() =>
rawRules.map((rule) => ({
@ -278,7 +307,13 @@ export function AutomationRulesDialog({
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => <AutomationRuleActions rule={row.original} />,
cell: ({ row }) => (
<AutomationRuleActions
rule={row.original}
categories={categories}
labels={labels}
/>
),
},
];
@ -325,7 +360,11 @@ export function AutomationRulesDialog({
}
className="max-w-sm"
/>
<CreateAutomationRuleDialog disabled={!isKeySet} />
<CreateAutomationRuleDialog
categories={categories}
labels={labels}
disabled={!isKeySet}
/>
</div>
<div className="max-h-[75vh] overflow-y-auto rounded-md border">
@ -358,6 +397,8 @@ export function AutomationRulesDialog({
<AutomationRuleRow
key={row.id}
row={row}
categories={categories}
labels={labels}
/>
))
) : (

View File

@ -19,26 +19,25 @@ import {
isValidRuleStructure,
type RuleStructure,
} from '@/lib/rule-builder-utils';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { categorySyncService } from '@/services/category-sync';
import { labelSyncService } from '@/services/label-sync';
import type { Category } from '@/types/category';
import type { Label } from '@/types/label';
import { router } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
interface CreateAutomationRuleDialogProps {
categories: Category[];
labels: Label[];
disabled?: boolean;
onSuccess?: () => void;
}
export function CreateAutomationRuleDialog({
categories,
labels,
disabled = false,
onSuccess,
}: CreateAutomationRuleDialogProps) {
const [open, setOpen] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const [labels, setLabels] = useState<Label[]>([]);
const [title, setTitle] = useState('');
const [priority, setPriority] = useState('10');
const [ruleStructure, setRuleStructure] = useState<RuleStructure>({
@ -50,18 +49,6 @@ export function CreateAutomationRuleDialog({
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
const loadData = async () => {
const [categoriesData, labelsData] = await Promise.all([
categorySyncService.getAll(),
labelSyncService.getAll(),
]);
setCategories(categoriesData);
setLabels(labelsData);
};
loadData();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
@ -107,7 +94,7 @@ export function CreateAutomationRuleDialog({
{
preserveState: true,
preserveScroll: true,
onSuccess: async () => {
onSuccess: () => {
setOpen(false);
setTitle('');
setPriority('10');
@ -118,7 +105,6 @@ export function CreateAutomationRuleDialog({
setCategoryId('');
setSelectedLabelIds([]);
setErrors({});
await automationRuleSyncService.sync();
onSuccess?.();
},
onError: (errors) => {
@ -223,12 +209,6 @@ export function CreateAutomationRuleDialog({
labels={labels}
placeholder="Select labels (optional)"
allowCreate={true}
onLabelCreated={(newLabel) => {
setLabels((prev) => [
...prev,
newLabel,
]);
}}
/>
</div>
</div>
@ -256,7 +236,11 @@ export function CreateAutomationRuleDialog({
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting} data-testid="submit-automation-rule">
<Button
type="submit"
disabled={isSubmitting}
data-testid="submit-automation-rule"
>
{isSubmitting ? 'Creating...' : 'Create'}
</Button>
</div>

View File

@ -9,7 +9,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import type { AutomationRule } from '@/types/automation-rule';
import { router } from '@inertiajs/react';
import { useState } from 'react';
@ -29,14 +28,13 @@ export function DeleteAutomationRuleDialog({
}: DeleteAutomationRuleDialogProps) {
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
const handleDelete = () => {
setIsDeleting(true);
router.delete(destroy(rule.id).url, {
preserveState: true,
preserveScroll: true,
onSuccess: async () => {
onSuccess: () => {
onOpenChange(false);
await automationRuleSyncService.sync();
onSuccess?.();
},
onFinish: () => {

View File

@ -19,9 +19,6 @@ import {
parseJsonLogic,
type RuleStructure,
} from '@/lib/rule-builder-utils';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { categorySyncService } from '@/services/category-sync';
import { labelSyncService } from '@/services/label-sync';
import type { AutomationRule } from '@/types/automation-rule';
import type { Category } from '@/types/category';
import type { Label as LabelType } from '@/types/label';
@ -30,6 +27,8 @@ import { useEffect, useState } from 'react';
interface EditAutomationRuleDialogProps {
rule: AutomationRule;
categories: Category[];
labels: LabelType[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
@ -37,12 +36,12 @@ interface EditAutomationRuleDialogProps {
export function EditAutomationRuleDialog({
rule,
categories,
labels,
open,
onOpenChange,
onSuccess,
}: EditAutomationRuleDialogProps) {
const [categories, setCategories] = useState<Category[]>([]);
const [labels, setLabels] = useState<LabelType[]>([]);
const [title, setTitle] = useState('');
const [priority, setPriority] = useState('0');
const [ruleStructure, setRuleStructure] = useState<RuleStructure>({
@ -54,18 +53,6 @@ export function EditAutomationRuleDialog({
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
const loadData = async () => {
const [categoriesData, labelsData] = await Promise.all([
categorySyncService.getAll(),
labelSyncService.getAll(),
]);
setCategories(categoriesData);
setLabels(labelsData);
};
loadData();
}, []);
useEffect(() => {
if (rule && open) {
setTitle(rule.title);
@ -123,10 +110,9 @@ export function EditAutomationRuleDialog({
{
preserveState: true,
preserveScroll: true,
onSuccess: async () => {
onSuccess: () => {
onOpenChange(false);
setErrors({});
await automationRuleSyncService.sync();
onSuccess?.();
},
onError: (errors) => {
@ -223,9 +209,6 @@ export function EditAutomationRuleDialog({
labels={labels}
placeholder="Select labels (optional)"
allowCreate={true}
onLabelCreated={(newLabel) => {
setLabels((prev) => [...prev, newLabel]);
}}
/>
</div>
@ -252,7 +235,11 @@ export function EditAutomationRuleDialog({
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting} data-testid="submit-automation-rule">
<Button
type="submit"
disabled={isSubmitting}
data-testid="submit-automation-rule"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>

View File

@ -19,7 +19,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { categorySyncService } from '@/services/category-sync';
import {
CATEGORY_COLORS,
CATEGORY_ICONS,
@ -53,8 +52,7 @@ export function CreateCategoryDialog({
</DialogHeader>
<Form
{...store.form()}
onSuccess={async () => {
await categorySyncService.sync();
onSuccess={() => {
setOpen(false);
onSuccess?.();
}}

View File

@ -8,7 +8,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { categorySyncService } from '@/services/category-sync';
import { type Category } from '@/types/category';
import { Form } from '@inertiajs/react';
@ -37,8 +36,7 @@ export function DeleteCategoryDialog({
</DialogHeader>
<Form
{...destroy.form.delete(category.id)}
onSuccess={async () => {
await categorySyncService.delete(category.id);
onSuccess={() => {
onOpenChange(false);
onSuccess?.();
}}

View File

@ -18,7 +18,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { categorySyncService } from '@/services/category-sync';
import {
CATEGORY_COLORS,
CATEGORY_ICONS,
@ -57,8 +56,7 @@ export function EditCategoryDialog({
</DialogHeader>
<Form
{...update.form.patch(category.id)}
onSuccess={async () => {
await categorySyncService.sync();
onSuccess={() => {
onOpenChange(false);
onSuccess?.();
}}

View File

@ -33,7 +33,7 @@ export function EncryptionKeyButton() {
if (!encryptedMessageData) {
fetchEncryptedMessage();
}
}, []);
}, [encryptedMessageData, fetchEncryptedMessage]);
function handleClick() {
if (isKeySet) {

View File

@ -18,7 +18,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { labelSyncService } from '@/services/label-sync';
import { getLabelColorClasses, LABEL_COLORS } from '@/types/label';
import { Form } from '@inertiajs/react';
import { useState } from 'react';
@ -40,8 +39,7 @@ export function CreateLabelDialog({ onSuccess }: { onSuccess?: () => void }) {
</DialogHeader>
<Form
{...store.form()}
onSuccess={async () => {
await labelSyncService.sync();
onSuccess={() => {
setOpen(false);
onSuccess?.();
}}

View File

@ -8,7 +8,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { labelSyncService } from '@/services/label-sync';
import { type Label } from '@/types/label';
import { Form } from '@inertiajs/react';
@ -37,8 +36,7 @@ export function DeleteLabelDialog({
</DialogHeader>
<Form
{...destroy.form.delete(label.id)}
onSuccess={async () => {
await labelSyncService.delete(label.id);
onSuccess={() => {
onOpenChange(false);
onSuccess?.();
}}

View File

@ -17,7 +17,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { labelSyncService } from '@/services/label-sync';
import {
getLabelColorClasses,
LABEL_COLORS,
@ -49,8 +48,7 @@ export function EditLabelDialog({
</DialogHeader>
<Form
{...update.form.patch(label.id)}
onSuccess={async () => {
await labelSyncService.sync();
onSuccess={() => {
onOpenChange(false);
onSuccess?.();
}}

View File

@ -2,6 +2,10 @@ import { StepHeader } from '@/components/onboarding/step-header';
import { ImportTransactionsDrawer } from '@/components/transactions/import-transactions-drawer';
import { Button } from '@/components/ui/button';
import { CreatedAccount } from '@/hooks/use-onboarding-state';
import { type Account, type Bank } from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
import { type Category } from '@/types/category';
import { usePage } from '@inertiajs/react';
import { ArrowRight, FileSpreadsheet, Upload } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
@ -16,6 +20,12 @@ export function StepImportTransactions({
}: StepImportTransactionsProps) {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [hasImported, setHasImported] = useState(false);
const { accounts, categories, banks, automationRules } = usePage<{
accounts: Account[];
categories: Category[];
banks: Bank[];
automationRules: AutomationRule[];
}>().props;
const handleDrawerClose = (open: boolean) => {
setIsDrawerOpen(open);
@ -113,6 +123,10 @@ export function StepImportTransactions({
<ImportTransactionsDrawer
open={isDrawerOpen}
onOpenChange={handleDrawerClose}
accounts={accounts}
categories={categories}
banks={banks}
automationRules={automationRules}
/>
</div>
);

View File

@ -1,30 +1,12 @@
import { StepButton } from '@/components/onboarding/step-button';
import { StepHeader } from '@/components/onboarding/step-header';
import { bankSyncService } from '@/services/bank-sync';
import { Bird } from 'lucide-react';
import { useEffect, useState } from 'react';
interface StepWelcomeProps {
onContinue: () => void;
}
export function StepWelcome({ onContinue }: StepWelcomeProps) {
const [isSyncing, setIsSyncing] = useState(true);
useEffect(() => {
const syncBanks = async () => {
try {
await bankSyncService.sync();
} catch (error) {
console.error('Failed to sync banks:', error);
} finally {
setIsSyncing(false);
}
};
syncBanks();
}, []);
return (
<div className="flex animate-in flex-col items-center text-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
@ -36,13 +18,7 @@ export function StepWelcome({ onContinue }: StepWelcomeProps) {
/>
<div className="flex w-full flex-col gap-4 sm:w-auto">
<StepButton
text="Let's Get Started"
onClick={onContinue}
disabled={isSyncing}
loading={isSyncing}
loadingText="Preparing..."
/>
<StepButton text="Let's Get Started" onClick={onContinue} />
<p className="text-sm text-muted-foreground">
This will take less than 5 minutes

View File

@ -1,136 +0,0 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { SyncStatus } from '@/contexts/sync-context';
import type { PendingChange } from '@/lib/dexie-db';
import { formatDistanceToNow } from 'date-fns';
import { RefreshCw } from 'lucide-react';
interface PendingOperationsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
operations: PendingChange[];
onSyncNow: () => void;
syncStatus: SyncStatus;
isOnline: boolean;
}
function getOperationBadgeClass(operation: PendingChange['operation']): string {
switch (operation) {
case 'create':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'update':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
case 'delete':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
}
}
function formatDataPreview(data: Record<string, unknown>): string {
if (data.id) {
const preview = `ID: ${String(data.id).slice(0, 8)}...`;
if (data.name) {
return `${preview}, Name: ${data.name}`;
}
if (data.description) {
return `${preview}, ${String(data.description).slice(0, 20)}...`;
}
return preview;
}
return JSON.stringify(data).slice(0, 50) + '...';
}
export function PendingOperationsDialog({
open,
onOpenChange,
operations,
onSyncNow,
syncStatus,
isOnline,
}: PendingOperationsDialogProps) {
const isSyncing = syncStatus === 'syncing';
const canSync = isOnline && !isSyncing;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Pending Operations</DialogTitle>
<DialogDescription>
These operations are waiting to be synced with the
server.
</DialogDescription>
</DialogHeader>
<div className="max-h-96 overflow-y-auto">
{operations.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground">
No pending operations
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Store</TableHead>
<TableHead>Operation</TableHead>
<TableHead>Data</TableHead>
<TableHead>Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{operations.map((op) => (
<TableRow key={op.id}>
<TableCell className="font-medium capitalize">
{op.store.replace('_', ' ')}
</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${getOperationBadgeClass(op.operation)}`}
>
{op.operation}
</span>
</TableCell>
<TableCell className="max-w-48 truncate text-xs text-muted-foreground">
{formatDataPreview(op.data)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDistanceToNow(
new Date(op.timestamp),
{ addSuffix: true },
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<div className="flex justify-end pt-2">
<Button
onClick={onSyncNow}
disabled={!canSync}
className="gap-2"
>
<RefreshCw
className={`h-4 w-4 ${isSyncing ? 'animate-spin' : ''}`}
/>
{isSyncing ? 'Syncing...' : 'Sync now'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,3 +1,4 @@
import { store } from '@/actions/App/Http/Controllers/Settings/LabelController';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -13,7 +14,6 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { labelSyncService } from '@/services/label-sync';
import { getLabelColorClasses, LABEL_COLORS, type Label } from '@/types/label';
import { Check, ChevronsUpDown, Plus, Tag, X } from 'lucide-react';
import { useState } from 'react';
@ -80,10 +80,31 @@ export function LabelCombobox({
try {
const randomColor =
LABEL_COLORS[Math.floor(Math.random() * LABEL_COLORS.length)];
const newLabel = await labelSyncService.findOrCreate(
inputValue.trim(),
randomColor,
);
const response = await fetch(store.url(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
),
Accept: 'application/json',
},
body: JSON.stringify({
name: inputValue.trim(),
color: randomColor,
}),
});
if (!response.ok) {
throw new Error('Failed to create label');
}
const data = await response.json();
const newLabel = data.data || data;
if (newLabel) {
onValueChange([...value, newLabel.id]);

View File

@ -9,27 +9,12 @@ import {
} from '@/components/ui/dropdown-menu';
import { useSyncContext } from '@/contexts/sync-context';
import { formatDistanceToNow } from 'date-fns';
import {
CloudAlert,
CloudCheck,
CloudOff,
List,
RefreshCw,
} from 'lucide-react';
import { CloudAlert, CloudCheck, CloudOff, RefreshCw } from 'lucide-react';
import { useState } from 'react';
import { PendingOperationsDialog } from './pending-operations-dialog';
export function SyncStatusButton() {
const {
syncStatus,
lastSyncTime,
isOnline,
sync,
error,
pendingOperationsCount,
pendingOperations,
} = useSyncContext();
const [showPendingDialog, setShowPendingDialog] = useState(false);
const { syncStatus, lastSyncTime, isOnline, sync, error } =
useSyncContext();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const getIcon = () => {
@ -73,7 +58,6 @@ export function SyncStatusButton() {
};
return (
<>
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
@ -82,28 +66,11 @@ export function SyncStatusButton() {
className={`relative ${isMenuOpen ? 'bg-accent' : ''} ${syncStatus === 'error' || !isOnline ? 'bg-red-100 dark:bg-red-900' : ''}`}
>
{getIcon()}
{pendingOperationsCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
{pendingOperationsCount > 99
? '99+'
: pendingOperationsCount}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col gap-1">
<p className="text-xs font-medium">
{getStatusText()}
</p>
<p className="text-xs text-muted-foreground">
{pendingOperationsCount} pending{' '}
{pendingOperationsCount === 1
? 'operation'
: 'operations'}
</p>
</div>
<p className="text-xs font-medium">{getStatusText()}</p>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
@ -118,23 +85,7 @@ export function SyncStatusButton() {
/>
Sync now
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setShowPendingDialog(true)}
>
<List className="mr-2 h-4 w-4" />
View pending operations
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<PendingOperationsDialog
open={showPendingDialog}
onOpenChange={setShowPendingDialog}
operations={pendingOperations}
onSyncNow={handleSyncNow}
syncStatus={syncStatus}
isOnline={isOnline}
/>
</>
);
}

View File

@ -1,3 +1,7 @@
import {
index as indexBalances,
store as storeBalance,
} from '@/actions/App/Http/Controllers/AccountBalanceController';
import { LabelCombobox } from '@/components/shared/label-combobox';
import { CategorySelect } from '@/components/transactions/category-select';
import { AmountInput } from '@/components/ui/amount-input';
@ -22,12 +26,11 @@ import {
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useSyncContext } from '@/contexts/sync-context';
import { decrypt, encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { evaluateRulesForNewTransaction } from '@/lib/rule-engine';
import { appendNoteIfNotPresent } from '@/lib/utils';
import { accountBalanceSyncService } from '@/services/account-balance-sync';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { transactionSyncService } from '@/services/transaction-sync';
import {
filterTransactionalAccounts,
@ -48,6 +51,7 @@ interface EditTransactionDialogProps {
accounts: Account[];
banks: Bank[];
labels: Label[];
automationRules?: AutomationRule[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: (transaction: DecryptedTransaction) => void;
@ -60,6 +64,7 @@ export function EditTransactionDialog({
accounts,
banks,
labels,
automationRules = [],
open,
onOpenChange,
onSuccess,
@ -69,6 +74,7 @@ export function EditTransactionDialog({
'whisper_money_update_balance_on_transaction';
const { isKeySet } = useEncryptionKey();
const { sync } = useSyncContext();
const [transactionDate, setTransactionDate] = useState('');
const [description, setDescription] = useState('');
const [amount, setAmount] = useState<number>(0);
@ -80,9 +86,6 @@ export function EditTransactionDialog({
const [decryptedAccountNames, setDecryptedAccountNames] = useState<
Map<string, string>
>(new Map());
const [automationRules, setAutomationRules] = useState<AutomationRule[]>(
[],
);
const [updateAccountBalance, setUpdateAccountBalance] = useState(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(STORAGE_KEY_UPDATE_BALANCE);
@ -161,21 +164,6 @@ export function EditTransactionDialog({
decryptAccountNames();
}, [open, mode, accounts]);
useEffect(() => {
if (!open || mode !== 'create') return;
async function loadAutomationRules() {
try {
const rules = await automationRuleSyncService.getAll();
setAutomationRules(rules);
} catch (error) {
console.error('Failed to load automation rules:', error);
}
}
loadAutomationRules();
}, [open, mode]);
async function checkAndApplyAutomationRules() {
if (mode !== 'create' || automationRules.length === 0) {
return {
@ -256,12 +244,31 @@ export function EditTransactionDialog({
transactionDateStr: string,
transactionAmount: number,
) {
const xsrfToken = decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
try {
const allBalances = await accountBalanceSyncService.getAll();
const accountBalances = allBalances
.filter((b) => b.account_id === accountIdToUpdate)
.sort(
(a, b) =>
// Fetch balances from backend
const balancesResponse = await fetch(
indexBalances.url(accountIdToUpdate),
{
headers: {
Accept: 'application/json',
},
},
);
if (!balancesResponse.ok) {
throw new Error('Failed to fetch balances');
}
const balancesData = await balancesResponse.json();
const accountBalances = (balancesData.data || []).sort(
(a: { balance_date: string }, b: { balance_date: string }) =>
new Date(b.balance_date).getTime() -
new Date(a.balance_date).getTime(),
);
@ -271,11 +278,26 @@ export function EditTransactionDialog({
const newBalance = latestBalance + transactionAmount;
await accountBalanceSyncService.updateOrCreate(
accountIdToUpdate as unknown as number,
transactionDateStr,
newBalance,
// Store new balance via backend
const storeResponse = await fetch(
storeBalance.url(accountIdToUpdate),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': xsrfToken,
Accept: 'application/json',
},
body: JSON.stringify({
balance_date: transactionDateStr,
balance: newBalance,
}),
},
);
if (!storeResponse.ok) {
throw new Error('Failed to store balance');
}
} catch (error) {
console.error('Failed to update account balance:', error);
toast.error('Transaction created, but failed to update balance');
@ -408,6 +430,9 @@ export function EditTransactionDialog({
onSuccess(newTransaction);
onOpenChange(false);
// Sync to update IndexedDB
sync();
} else {
if (!transaction) {
return;
@ -493,6 +518,9 @@ export function EditTransactionDialog({
toast.success('Transaction updated successfully');
onSuccess(updatedTransaction);
onOpenChange(false);
// Sync to update IndexedDB
sync();
}
} catch (error) {
console.error('Failed to save transaction:', error);
@ -679,7 +707,10 @@ export function EditTransactionDialog({
onValueChange={setAccountId}
disabled={isSubmitting}
>
<SelectTrigger id="account" data-testid="account-select">
<SelectTrigger
id="account"
data-testid="account-select"
>
<SelectValue placeholder="Select account" />
</SelectTrigger>
<SelectContent>
@ -748,7 +779,11 @@ export function EditTransactionDialog({
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting} data-testid="submit-transaction">
<Button
type="submit"
disabled={isSubmitting}
data-testid="submit-transaction"
>
{isSubmitting
? 'Saving...'
: mode === 'create'

View File

@ -2,58 +2,33 @@ import { EncryptedText } from '@/components/encrypted-text';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { accountSyncService } from '@/services/account-sync';
import { filterTransactionalAccounts, type Account } from '@/types/account';
import type { UUID } from '@/types/uuid';
import { Building2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
interface ImportStepAccountProps {
accounts?: Account[];
selectedAccountId: UUID | null;
onAccountSelect: (accountId: UUID) => void;
onNext: () => void;
}
export function ImportStepAccount({
accounts: rawAccounts = [],
selectedAccountId,
onAccountSelect,
onNext,
}: ImportStepAccountProps) {
const [accounts, setAccounts] = useState<Account[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadAccounts = async () => {
try {
const data = await accountSyncService.getAll();
setAccounts(filterTransactionalAccounts(data));
} catch (error) {
console.error('Failed to load accounts:', error);
} finally {
setLoading(false);
}
};
loadAccounts();
}, []);
const accounts = filterTransactionalAccounts(rawAccounts);
// If there is only one account, auto-select it, and proceed to next step
useEffect(() => {
if (!loading && accounts.length === 1) {
if (accounts.length === 1) {
onAccountSelect(accounts[0].id);
onNext();
}
}, [loading, accounts, onAccountSelect, onNext]);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<p className="text-sm text-muted-foreground">
Loading accounts...
</p>
</div>
);
}
}, [accounts, onAccountSelect, onNext]);
if (accounts.length === 0) {
return (

View File

@ -6,23 +6,51 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { type Account, type Bank } from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
import { type Category } from '@/types/category';
import { Upload } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { ImportTransactionsDrawer } from './import-transactions-drawer';
interface ImportData {
accounts: Account[];
categories: Category[];
banks: Bank[];
automationRules: AutomationRule[];
}
export function ImportTransactionsButton() {
const { isKeySet } = useEncryptionKey();
const [drawerOpen, setDrawerOpen] = useState(false);
const [importData, setImportData] = useState<ImportData | null>(null);
const [loading, setLoading] = useState(false);
const handleOpenDrawer = () => {
const handleOpenDrawer = async () => {
if (!isKeySet) {
toast.error(
'Please unlock your encryption key to import transactions',
);
return;
}
// Fetch data on-demand when drawer opens
setLoading(true);
try {
const response = await fetch('/api/import/data');
if (!response.ok) {
throw new Error('Failed to load import data');
}
const data = await response.json();
setImportData(data);
setDrawerOpen(true);
} catch (error) {
toast.error('Failed to load import data');
console.error(error);
} finally {
setLoading(false);
}
};
return (
@ -32,12 +60,15 @@ export function ImportTransactionsButton() {
<TooltipTrigger asChild>
<Button
variant="ghost"
className={`h-9 ${!isKeySet ? 'cursor-not-allowed opacity-50' : ''}`}
className={`h-9 ${!isKeySet || loading ? 'cursor-not-allowed opacity-50' : ''}`}
onClick={handleOpenDrawer}
disabled={loading}
aria-label="Import transactions"
>
<Upload className="h-5 w-5" />
<span className="">Import</span>
<span className="">
{loading ? 'Loading...' : 'Import'}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
@ -48,10 +79,16 @@ export function ImportTransactionsButton() {
</Tooltip>
</TooltipProvider>
{importData && (
<ImportTransactionsDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
accounts={importData.accounts}
categories={importData.categories}
banks={importData.banks}
automationRules={importData.automationRules}
/>
)}
</>
);
}

View File

@ -1,3 +1,4 @@
import { store as storeBalance } from '@/actions/App/Http/Controllers/AccountBalanceController';
import AlertError from '@/components/alert-error';
import {
Drawer,
@ -22,13 +23,10 @@ import {
import { getStoredKey } from '@/lib/key-storage';
import { evaluateRulesForNewTransaction } from '@/lib/rule-engine';
import { useTrackEvent } from '@/lib/track-event';
import { accountBalanceSyncService } from '@/services/account-balance-sync';
import { accountSyncService } from '@/services/account-sync';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { bankSyncService } from '@/services/bank-sync';
import { categorySyncService } from '@/services/category-sync';
import { transactionSyncService } from '@/services/transaction-sync';
import { type Account } from '@/types/account';
import { type Account, type Bank } from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
import { type Category } from '@/types/category';
import {
DateFormat,
ImportStep,
@ -44,6 +42,10 @@ import { ImportStepPreview } from './import-step-preview';
import { ImportStepUpload } from './import-step-upload';
interface ImportTransactionsDrawerProps {
accounts?: Account[];
categories?: Category[];
banks?: Bank[];
automationRules?: AutomationRule[];
open: boolean;
onOpenChange: (open: boolean) => void;
}
@ -67,6 +69,10 @@ type ImportFunnelStep =
| 'Finish';
export function ImportTransactionsDrawer({
accounts = [],
categories = [],
banks = [],
automationRules = [],
open,
onOpenChange,
}: ImportTransactionsDrawerProps) {
@ -100,15 +106,14 @@ export function ImportTransactionsDrawer({
useEffect(() => {
if (state.selectedAccountId) {
accountSyncService
.getById(state.selectedAccountId)
.then((account) => {
const account = accounts.find(
(a) => a.id === state.selectedAccountId,
);
if (account) {
setSelectedAccount(account);
}
});
}
}, [state.selectedAccountId]);
}, [state.selectedAccountId, accounts]);
const trackFunnelStep = useCallback(
(step: ImportFunnelStep) => {
@ -281,8 +286,8 @@ export function ImportTransactionsDrawer({
state.dateFormat,
);
const account = await accountSyncService.getById(
state.selectedAccountId!,
const account = accounts.find(
(a) => a.id === state.selectedAccountId,
);
if (!account) {
@ -352,11 +357,7 @@ export function ImportTransactionsDrawer({
const errors: ImportError[] = [];
const keyString = getStoredKey();
const key = keyString ? await importKey(keyString) : null;
const rules = key ? await automationRuleSyncService.getAll() : [];
const freshAccounts = await accountSyncService.getAll();
const freshBanks = await bankSyncService.getAll();
const freshCategories = await categorySyncService.getAll();
const rules = key ? automationRules : [];
const BATCH_SIZE = 20;
let processedCount = 0;
@ -387,9 +388,9 @@ export function ImportTransactionsDrawer({
account_id: selectedAccount.id,
},
rules,
freshCategories,
freshAccounts,
freshBanks,
categories,
accounts,
banks,
key,
);
@ -488,15 +489,29 @@ export function ImportTransactionsDrawer({
if (balancesToImport.size > 0) {
try {
const balanceRecords = Array.from(
balancesToImport.entries(),
).map(([date, balance]) => ({
account_id: selectedAccount.id,
const xsrfToken = decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
const balanceRecords = Array.from(balancesToImport.entries());
for (const [date, balance] of balanceRecords) {
await fetch(storeBalance.url(selectedAccount.id), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': xsrfToken,
Accept: 'application/json',
},
body: JSON.stringify({
balance_date: date,
balance,
}));
await accountBalanceSyncService.createMany(balanceRecords);
}),
});
}
} catch (err) {
console.error('Failed to import balances:', err);
}
@ -571,10 +586,6 @@ export function ImportTransactionsDrawer({
syncError,
);
});
accountBalanceSyncService.sync().catch((syncError) => {
console.error('Failed to sync balances with backend:', syncError);
});
};
const handleSelectionChange = (index: number, selected: boolean) => {
@ -645,6 +656,7 @@ export function ImportTransactionsDrawer({
case ImportStep.SelectAccount:
return (
<ImportStepAccount
accounts={accounts}
selectedAccountId={state.selectedAccountId}
onAccountSelect={handleAccountSelect}
onNext={() => {

View File

@ -1,7 +1,7 @@
import { format } from 'date-fns';
import * as Icons from 'lucide-react';
import { Check, ChevronsUpDown, Tag, X } from 'lucide-react';
import { type ReactNode, useState } from 'react';
import { type ReactNode, useEffect, useState } from 'react';
import { EncryptedText } from '@/components/encrypted-text';
import { Badge } from '@/components/ui/badge';
@ -51,10 +51,34 @@ export function TransactionFilters({
const [isOpen, setIsOpen] = useState(false);
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
const [labelDropdownOpen, setLabelDropdownOpen] = useState(false);
const [searchText, setSearchText] = useState(filters.searchText);
const isUncategorizedSelected = filters.categoryIds.includes(
UNCATEGORIZED_CATEGORY_ID,
);
// Debounce search text updates
useEffect(() => {
const timer = setTimeout(() => {
if (searchText !== filters.searchText) {
onFiltersChange({
...filters,
searchText,
});
}
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchText]);
// Sync local state when filters change externally
useEffect(() => {
if (filters.searchText !== searchText) {
setSearchText(filters.searchText);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.searchText]);
function handleCategoryToggle(categoryId: number) {
const newCategoryIds = filters.categoryIds.includes(categoryId)
? filters.categoryIds.filter((id) => id !== categoryId)
@ -80,6 +104,7 @@ export function TransactionFilters({
}
function clearFilters() {
setSearchText('');
onFiltersChange({
dateFrom: null,
dateTo: null,
@ -111,13 +136,8 @@ export function TransactionFilters({
? 'Search description or notes...'
: 'Search disabled (encryption key not set)'
}
value={filters.searchText}
onChange={(e) =>
onFiltersChange({
...filters,
searchText: e.target.value,
})
}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
disabled={!isKeySet}
className="max-w-sm flex-1 md:max-w-full md:min-w-[350px]"
/>

View File

@ -58,9 +58,9 @@ import { db } from '@/lib/dexie-db';
import { getStoredKey } from '@/lib/key-storage';
import { evaluateRules } from '@/lib/rule-engine';
import { appendNoteIfNotPresent } from '@/lib/utils';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { transactionSyncService } from '@/services/transaction-sync';
import { type Account, type Bank } from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
import { type Category } from '@/types/category';
import { type Label } from '@/types/label';
import {
@ -216,6 +216,7 @@ export interface TransactionListProps {
accounts: Account[];
banks: Bank[];
labels?: Label[];
automationRules?: AutomationRule[];
accountId?: UUID;
pageSize?: number;
hideAccountFilter?: boolean;
@ -230,6 +231,7 @@ export function TransactionList({
accounts,
banks,
labels: initialLabels = [],
automationRules = [],
accountId,
pageSize = 25,
hideAccountFilter = false,
@ -252,7 +254,7 @@ export function TransactionList({
'',
);
const labels = useLiveQuery(() => db.labels.toArray(), [], initialLabels);
const labels = initialLabels;
const [transactions, setTransactions] = useState<DecryptedTransaction[]>(
[],
@ -664,10 +666,11 @@ export function TransactionList({
consoleDebug('✓ Encryption key found');
const key = await importKey(keyString);
const rules = await automationRuleSyncService.getAll();
consoleDebug(`Found ${rules.length} automation rules`);
consoleDebug(
`Found ${automationRules.length} automation rules`,
);
if (rules.length === 0) {
if (automationRules.length === 0) {
consoleDebug('❌ No rules to evaluate');
return;
}
@ -675,7 +678,7 @@ export function TransactionList({
consoleDebug('Evaluating rules against transaction...');
const result = await evaluateRules(
transaction,
rules,
automationRules,
categories,
accounts,
banks,
@ -765,7 +768,14 @@ export function TransactionList({
consoleDebug('=== Re-evaluation complete ===');
}
},
[isKeySet, categories, accounts, banks, updateTransaction],
[
isKeySet,
categories,
accounts,
banks,
updateTransaction,
automationRules,
],
);
async function handleBulkReEvaluateRules() {
@ -792,10 +802,9 @@ export function TransactionList({
consoleDebug('✓ Encryption key found');
const key = await importKey(keyString);
const rules = await automationRuleSyncService.getAll();
consoleDebug(`Found ${rules.length} automation rules`);
consoleDebug(`Found ${automationRules.length} automation rules`);
if (rules.length === 0) {
if (automationRules.length === 0) {
consoleDebug('❌ No rules to evaluate');
return;
}
@ -825,7 +834,7 @@ export function TransactionList({
consoleDebug(`\nEvaluating transaction ${transaction.id}...`);
const result = await evaluateRules(
transaction,
rules,
automationRules,
categories,
accounts,
banks,

View File

@ -1,18 +1,8 @@
import { useOnlineStatus } from '@/hooks/use-online-status';
import { checkDatabaseVersion } from '@/lib/db-migration-helper';
import { db, type PendingChange } from '@/lib/dexie-db';
import { handleUserChange } from '@/lib/user-session-storage';
import { accountBalanceSyncService } from '@/services/account-balance-sync';
import { accountSyncService } from '@/services/account-sync';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { bankSyncService } from '@/services/bank-sync';
import { categorySyncService } from '@/services/category-sync';
import { labelSyncService } from '@/services/label-sync';
import { transactionSyncService } from '@/services/transaction-sync';
import type { User } from '@/types/index.d';
import type { Page } from '@inertiajs/core';
import { router } from '@inertiajs/react';
import { useLiveQuery } from 'dexie-react-hooks';
import {
createContext,
useCallback,
@ -32,9 +22,6 @@ interface SyncContextType {
isAuthenticated: boolean;
sync: () => Promise<void>;
error: string | null;
pendingOperationsCount: number;
pendingOperations: PendingChange[];
refreshPendingOperations: () => Promise<void>;
}
const SyncContext = createContext<SyncContextType | undefined>(undefined);
@ -64,9 +51,6 @@ function formatErrorMessage(error: string): string {
return 'Sync failed. Please try again.';
}
const SYNC_INTERVAL = 5 * 60 * 1000;
const AUTO_SYNC_DEBOUNCE = 500; // Auto-sync 500ms after pending changes detected
interface SyncProviderProps {
children: ReactNode;
initialIsAuthenticated: boolean;
@ -88,16 +72,7 @@ export function SyncProvider({
const [error, setError] = useState<string | null>(null);
const [wasOffline, setWasOffline] = useState(!isOnline);
const syncInProgressRef = useRef(false);
const userChangeCheckedRef = useRef(false);
const autoSyncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const pendingOperations =
useLiveQuery(() => db.pending_changes.toArray(), []) || [];
const pendingOperationsCount = pendingOperations.length;
const refreshPendingOperations = useCallback(async () => {
// No-op: useLiveQuery handles reactivity automatically
}, []);
const lastUserIdRef = useRef<string | null>(null);
useEffect(() => {
const unsubscribe = router.on('navigate', (event) => {
@ -146,37 +121,11 @@ export function SyncProvider({
setError(null);
try {
const [
categoriesResult,
accountsResult,
accountBalancesResult,
banksResult,
automationRulesResult,
labelsResult,
transactionsResult,
] = await Promise.all([
categorySyncService.sync(),
accountSyncService.sync(),
accountBalanceSyncService.sync(),
bankSyncService.sync(),
automationRuleSyncService.sync(),
labelSyncService.sync(),
transactionSyncService.sync(),
]);
const result = await transactionSyncService.sync();
const allErrors = [
...categoriesResult.errors,
...accountsResult.errors,
...accountBalancesResult.errors,
...banksResult.errors,
...automationRulesResult.errors,
...labelsResult.errors,
...transactionsResult.errors,
];
if (allErrors.length > 0) {
if (result.errors.length > 0) {
const uniqueFormattedErrors = [
...new Set(allErrors.map(formatErrorMessage)),
...new Set(result.errors.map(formatErrorMessage)),
];
setError(uniqueFormattedErrors.join(' '));
setSyncStatus('error');
@ -210,78 +159,18 @@ export function SyncProvider({
setWasOffline(!isOnline);
}, [isAuthenticated, isOnline, wasOffline, sync]);
useEffect(() => {
if (!isOnline || !isAuthenticated) {
return;
}
const interval = setInterval(() => {
sync();
}, SYNC_INTERVAL);
return () => clearInterval(interval);
}, [isAuthenticated, isOnline, sync]);
useEffect(() => {
if (!isAuthenticated || !currentUser) {
return;
}
const checkUserAndSync = async () => {
if (userChangeCheckedRef.current) {
return;
// If user changed, clear transactions
if (lastUserIdRef.current && lastUserIdRef.current !== currentUser.id) {
transactionSyncService.clearAll();
}
userChangeCheckedRef.current = true;
const wasCleared = await handleUserChange(currentUser.id);
if (wasCleared) {
window.location.reload();
return;
}
const { needsRefresh, missingStores } =
await checkDatabaseVersion();
if (needsRefresh) {
console.warn(
'Database needs update. Missing stores:',
missingStores,
'\nPlease refresh the page with Ctrl+Shift+R (or Cmd+Shift+R on Mac)',
);
}
sync();
};
checkUserAndSync();
// eslint-disable-next-line react-hooks/exhaustive-deps
lastUserIdRef.current = currentUser.id;
}, [isAuthenticated, currentUser]);
// Auto-sync when pending changes are detected
useEffect(() => {
if (!isAuthenticated || !isOnline || pendingOperationsCount === 0) {
return;
}
// Clear any existing timeout
if (autoSyncTimeoutRef.current) {
clearTimeout(autoSyncTimeoutRef.current);
}
// Debounce sync to avoid too many calls
autoSyncTimeoutRef.current = setTimeout(() => {
sync();
}, AUTO_SYNC_DEBOUNCE);
return () => {
if (autoSyncTimeoutRef.current) {
clearTimeout(autoSyncTimeoutRef.current);
}
};
}, [isAuthenticated, isOnline, pendingOperationsCount, sync]);
return (
<SyncContext.Provider
value={{
@ -291,9 +180,6 @@ export function SyncProvider({
isAuthenticated,
sync,
error,
pendingOperationsCount,
pendingOperations,
refreshPendingOperations,
}}
>
{children}

View File

@ -2,9 +2,9 @@ import { decrypt, encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { evaluateRules } from '@/lib/rule-engine';
import { appendNoteIfNotPresent } from '@/lib/utils';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { transactionSyncService } from '@/services/transaction-sync';
import type { Account, Bank } from '@/types/account';
import type { AutomationRule } from '@/types/automation-rule';
import type { Category } from '@/types/category';
import type { DecryptedTransaction } from '@/types/transaction';
import { useCallback } from 'react';
@ -26,6 +26,7 @@ export function useReEvaluateAllTransactions() {
categories: Category[],
accounts: Account[],
banks: Bank[],
automationRules: AutomationRule[],
options?: ReEvaluateAllOptions,
) => {
if (!transactions.length) {
@ -40,9 +41,8 @@ export function useReEvaluateAllTransactions() {
}
const key = await importKey(keyString);
const rules = await automationRuleSyncService.getAll();
if (!rules.length) {
if (!automationRules.length) {
toast.error('No automation rules found');
return;
}
@ -67,7 +67,7 @@ export function useReEvaluateAllTransactions() {
const result = await evaluateRules(
transaction,
rules,
automationRules,
categories,
accounts,
banks,

View File

@ -1,43 +0,0 @@
import { db } from './dexie-db';
export async function checkDatabaseVersion(): Promise<{
needsRefresh: boolean;
missingStores: string[];
}> {
const requiredStores = [
'categories',
'accounts',
'banks',
'automation_rules',
'transactions',
'sync_metadata',
'pending_changes',
];
try {
await db.open();
const existingStores = db.tables.map((table) => table.name);
const missingStores = requiredStores.filter(
(store) => !existingStores.includes(store),
);
if (missingStores.length > 0) {
console.warn(
'Missing IndexedDB stores:',
missingStores,
'\nPlease refresh the page (Ctrl+Shift+R or Cmd+Shift+R)',
);
}
return {
needsRefresh: missingStores.length > 0,
missingStores,
};
} catch (error) {
console.error('Failed to check database version:', error);
return {
needsRefresh: false,
missingStores: [],
};
}
}

View File

@ -1,8 +1,4 @@
import type { Transaction } from '@/services/transaction-sync';
import type { Account, AccountBalance, Bank } from '@/types/account';
import type { AutomationRule } from '@/types/automation-rule';
import type { Category } from '@/types/category';
import type { Label } from '@/types/label';
import type { Transaction } from '@/types/transaction';
import Dexie, { type EntityTable } from 'dexie';
export interface SyncMetadata {
@ -10,27 +6,17 @@ export interface SyncMetadata {
value: string;
}
export interface PendingChange {
id?: number;
store: string;
operation: 'create' | 'update' | 'delete';
data: Record<string, unknown>;
timestamp: string;
}
const db = new Dexie('whisper_money') as Dexie & {
type WhisperMoneyDB = Dexie & {
transactions: EntityTable<Transaction, 'id'>;
accounts: EntityTable<Account, 'id'>;
categories: EntityTable<Category, 'id'>;
labels: EntityTable<Label, 'id'>;
banks: EntityTable<Bank, 'id'>;
automation_rules: EntityTable<AutomationRule, 'id'>;
account_balances: EntityTable<AccountBalance, 'id'>;
sync_metadata: EntityTable<SyncMetadata, 'key'>;
pending_changes: EntityTable<PendingChange, 'id'>;
};
db.version(5).stores({
let dbInstance: WhisperMoneyDB | null = null;
function initializeDatabase(): WhisperMoneyDB {
const database = new Dexie('whisper_money') as WhisperMoneyDB;
database.version(5).stores({
transactions: 'id, user_id, account_id, updated_at',
accounts: 'id, user_id, bank_id, updated_at',
categories: 'id, user_id, updated_at',
@ -39,9 +25,9 @@ db.version(5).stores({
account_balances: 'id, account_id, balance_date, updated_at',
sync_metadata: 'key',
pending_changes: '++id, store, timestamp',
});
});
db.version(6).stores({
database.version(6).stores({
transactions: 'id, user_id, account_id, updated_at',
accounts: 'id, user_id, bank_id, updated_at',
categories: 'id, user_id, updated_at',
@ -51,6 +37,36 @@ db.version(6).stores({
account_balances: 'id, account_id, balance_date, updated_at',
sync_metadata: 'key',
pending_changes: '++id, store, timestamp',
});
});
export { db };
// Version 7: Remove all tables except transactions and sync_metadata
database.version(7).stores({
transactions: 'id, user_id, account_id, updated_at',
sync_metadata: 'key',
// Delete removed tables
accounts: null,
categories: null,
labels: null,
banks: null,
automation_rules: null,
account_balances: null,
pending_changes: null,
});
// Version 8: Ensure clean state (no schema changes, just trigger upgrade)
database.version(8).stores({
transactions: 'id, user_id, account_id, updated_at',
sync_metadata: 'key',
});
return database;
}
export const db = new Proxy({} as WhisperMoneyDB, {
get(_target, prop) {
if (!dbInstance) {
dbInstance = initializeDatabase();
}
return dbInstance[prop as keyof WhisperMoneyDB];
},
});

View File

@ -1,58 +1,40 @@
import type { Transaction } from '@/types/transaction';
import type { UUID } from '@/types/uuid';
import axios, { AxiosError } from 'axios';
import { uuidv7 } from 'uuidv7';
import axios from 'axios';
import { db } from './dexie-db';
export type StoreName =
| 'categories'
| 'accounts'
| 'banks'
| 'automation_rules'
| 'transactions';
export interface IndexedDBRecord {
id: UUID;
user_id?: UUID | null;
created_at: string;
updated_at: string;
[key: string]: unknown;
}
export interface SyncOptions {
storeName: StoreName;
endpoint: string;
transformFromServer?: (
data: Record<string, unknown>,
) => Record<string, unknown>;
transformToServer?: (
data: Record<string, unknown>,
) => Record<string, unknown>;
}
export interface SyncResult {
success: boolean;
inserted: number;
updated: number;
deleted: number;
errors: string[];
}
export class SyncManager {
private syncInProgress = false;
private lastSyncKey: string;
interface SyncOptions {
endpoint: string;
transformFromServer?: (
data: Record<string, unknown>,
) => Record<string, unknown>;
}
constructor(private options: SyncOptions) {
this.lastSyncKey = `last_sync_${options.storeName}`;
const LAST_SYNC_KEY = 'last_sync_transactions';
export class TransactionSyncManager {
private syncInProgress = false;
private options: SyncOptions;
constructor(options: SyncOptions) {
this.options = options;
}
async getLastSyncTime(): Promise<string | null> {
const metadata = await db.sync_metadata.get(this.lastSyncKey);
const metadata = await db.sync_metadata.get(LAST_SYNC_KEY);
return metadata?.value || null;
}
async setLastSyncTime(timestamp: string): Promise<void> {
await db.sync_metadata.put({
key: this.lastSyncKey,
key: LAST_SYNC_KEY,
value: timestamp,
});
}
@ -63,7 +45,6 @@ export class SyncManager {
success: false,
inserted: 0,
updated: 0,
deleted: 0,
errors: ['Sync already in progress'],
};
}
@ -74,18 +55,11 @@ export class SyncManager {
success: true,
inserted: 0,
updated: 0,
deleted: 0,
errors: [],
};
try {
await this.syncFromServer(result);
const successfulChangeIds = await this.syncToServer(result);
if (successfulChangeIds.length > 0) {
await db.pending_changes.bulkDelete(successfulChangeIds);
}
await this.setLastSyncTime(new Date().toISOString());
} catch (error) {
result.success = false;
@ -115,22 +89,18 @@ export class SyncManager {
throw new Error('Invalid server response format');
}
const table = db[this.options.storeName];
const localRecords = await table.toArray();
const localMap = new Map(
localRecords.map((r) => [
(r as IndexedDBRecord).id,
r as IndexedDBRecord,
]),
);
const localRecords = await db.transactions.toArray();
const localMap = new Map(localRecords.map((r) => [r.id, r]));
const toInsert: Record<string, unknown>[] = [];
const toUpdate: Record<string, unknown>[] = [];
const toInsert: Transaction[] = [];
const toUpdate: Transaction[] = [];
for (const serverRecord of serverData) {
const transformed = this.options.transformFromServer
const transformed = (
this.options.transformFromServer
? this.options.transformFromServer(serverRecord)
: serverRecord;
: serverRecord
) as Transaction;
const localRecord = localMap.get(transformed.id);
@ -147,163 +117,37 @@ export class SyncManager {
}
if (toInsert.length > 0) {
await table.bulkPut(toInsert);
await db.transactions.bulkPut(toInsert);
result.inserted += toInsert.length;
}
if (toUpdate.length > 0) {
await table.bulkPut(toUpdate);
await db.transactions.bulkPut(toUpdate);
result.updated += toUpdate.length;
}
}
private async syncToServer(result: SyncResult): Promise<number[]> {
const pendingChanges = await db.pending_changes
.where('store')
.equals(this.options.storeName)
async getAll(): Promise<Transaction[]> {
return await db.transactions.toArray();
}
async getById(id: UUID): Promise<Transaction | null> {
return (await db.transactions.get(id)) || null;
}
async getByAccountId(accountId: UUID): Promise<Transaction[]> {
return await db.transactions
.where('account_id')
.equals(accountId)
.toArray();
const successfulChangeIds: number[] = [];
for (const change of pendingChanges) {
try {
const data = this.options.transformToServer
? this.options.transformToServer(change.data)
: change.data;
switch (change.operation) {
case 'create':
await axios.post(this.options.endpoint, data);
result.inserted++;
break;
case 'update':
try {
await axios.patch(
`${this.options.endpoint}/${change.data.id}`,
data,
);
result.updated++;
} catch (updateError) {
if (
updateError instanceof AxiosError &&
updateError.response?.status === 404
) {
await axios.post(this.options.endpoint, data);
result.inserted++;
} else {
throw updateError;
}
}
break;
case 'delete':
try {
await axios.delete(
`${this.options.endpoint}/${change.data.id}`,
);
result.deleted++;
} catch (deleteError) {
if (
deleteError instanceof AxiosError &&
deleteError.response?.status === 404
) {
result.deleted++;
} else {
throw deleteError;
}
}
break;
}
if (change.id !== undefined) {
successfulChangeIds.push(change.id);
}
} catch (error) {
result.errors.push(
`Failed to sync ${change.operation} for ${this.options.storeName}: ${error}`,
);
}
}
return successfulChangeIds;
}
async createLocal<T extends IndexedDBRecord>(
data: Omit<T, 'id' | 'created_at' | 'updated_at'>,
): Promise<T> {
const timestamp = new Date().toISOString();
const id = uuidv7();
const record = {
...data,
id,
created_at: timestamp,
updated_at: timestamp,
} as T;
const table = db[this.options.storeName];
await table.put(record);
await db.pending_changes.add({
store: this.options.storeName,
operation: 'create',
data: record,
timestamp,
});
return record;
}
async updateLocal<T extends IndexedDBRecord>(
id: UUID,
data: Partial<T>,
): Promise<void> {
const table = db[this.options.storeName];
const existing = await table.get(id);
if (!existing) {
throw new Error(
`Record ${id} not found in ${this.options.storeName}`,
);
}
const timestamp = new Date().toISOString();
const updated = {
...existing,
...data,
updated_at: timestamp,
};
await table.put(updated);
await db.pending_changes.add({
store: this.options.storeName,
operation: 'update',
data: updated,
timestamp,
});
}
async deleteLocal(id: UUID): Promise<void> {
const timestamp = new Date().toISOString();
const table = db[this.options.storeName];
await table.delete(id);
await db.pending_changes.add({
store: this.options.storeName,
operation: 'delete',
data: { id },
timestamp,
});
}
async getAll<T extends IndexedDBRecord>(): Promise<T[]> {
const table = db[this.options.storeName];
return (await table.toArray()) as T[];
}
async getById<T extends IndexedDBRecord>(id: UUID): Promise<T | null> {
const table = db[this.options.storeName];
return ((await table.get(id)) as T) || null;
}
isSyncing(): boolean {
return this.syncInProgress;
}
async clearAll(): Promise<void> {
await db.transactions.clear();
await db.sync_metadata.delete(LAST_SYNC_KEY);
}
}

View File

@ -1,74 +0,0 @@
import type { UUID } from '@/types/uuid';
import { db } from './dexie-db';
const USER_ID_KEY = 'current_user_id';
function isBrowser(): boolean {
return typeof window !== 'undefined';
}
export async function getStoredUserId(): Promise<UUID | null> {
if (!isBrowser()) return null;
try {
await db.open();
const metadata = await db.sync_metadata.get(USER_ID_KEY);
return (metadata?.value as UUID) || null;
} catch {
return null;
}
}
export async function setStoredUserId(userId: UUID): Promise<void> {
if (!isBrowser()) return;
try {
await db.open();
await db.sync_metadata.put({ key: USER_ID_KEY, value: userId });
} catch (error) {
console.error('Failed to store user ID:', error);
}
}
export async function clearAllUserData(): Promise<void> {
if (!isBrowser()) return;
// Clear all tables including sync_metadata (which contains last_sync timestamps)
// This ensures a fresh start for the new user without stale sync timestamps
try {
await db.open();
await Promise.all([
db.transactions.clear(),
db.accounts.clear(),
db.categories.clear(),
db.banks.clear(),
db.automation_rules.clear(),
db.account_balances.clear(),
db.sync_metadata.clear(),
db.pending_changes.clear(),
]);
} catch {
// If clearing fails, delete and recreate the database
await db.delete();
await db.open();
}
}
export async function handleUserChange(newUserId: UUID): Promise<boolean> {
const storedUserId = await getStoredUserId();
// Different user logged in - clear all data and set new user
if (storedUserId && storedUserId !== newUserId) {
await clearAllUserData();
await setStoredUserId(newUserId);
return true;
}
// No stored user ID (first time or after signup/clear) - store the new user ID
if (!storedUserId) {
await setStoredUserId(newUserId);
}
// Same user or just stored - no clearing needed
return false;
}

View File

@ -205,6 +205,7 @@ export default function AccountShow({
<ImportBalancesDrawer
open={importBalancesOpen}
onOpenChange={setImportBalancesOpen}
accounts={accounts}
accountId={account.id}
onSuccess={handleBalanceUpdated}
/>

View File

@ -10,7 +10,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/auth-layout';
import { clearAllUserData } from '@/lib/user-session-storage';
import { transactionSyncService } from '@/services/transaction-sync';
interface RegisterProps {
hideAuthButtons?: boolean;
@ -24,7 +24,7 @@ export default function Register({ hideAuthButtons = false }: RegisterProps) {
}, [hideAuthButtons]);
const handleBeforeSubmit = useCallback(async () => {
await clearAllUserData();
await transactionSyncService.clearAll();
return true;
}, []);

View File

@ -1,4 +1,4 @@
import { Head } from '@inertiajs/react';
import { Head, router } from '@inertiajs/react';
import {
Cell,
ColumnDef,
@ -13,7 +13,6 @@ import {
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import { useLiveQuery } from 'dexie-react-hooks';
import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
import { useState } from 'react';
@ -50,8 +49,6 @@ import {
} from '@/components/ui/table';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { db } from '@/lib/dexie-db';
import { accountSyncService } from '@/services/account-sync';
import { type BreadcrumbItem } from '@/types';
import { type Account, formatAccountType } from '@/types/account';
@ -76,7 +73,11 @@ function AccountActions({
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0" aria-label="Open menu">
<Button
variant="ghost"
className="h-8 w-8 p-0"
aria-label="Open menu"
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
@ -175,16 +176,19 @@ function AccountRow({
);
}
export default function Accounts() {
const accounts = useLiveQuery(() => db.accounts.toArray(), []) || [];
interface AccountsPageProps {
accounts: Account[];
}
export default function Accounts({ accounts }: AccountsPageProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
{},
);
const handleAccountCreated = async () => {
await accountSyncService.sync();
const handleAccountCreated = () => {
router.reload({ only: ['accounts'] });
};
const columns: ColumnDef<Account>[] = [

View File

@ -1,4 +1,4 @@
import { Head } from '@inertiajs/react';
import { Head, usePage } from '@inertiajs/react';
import {
Cell,
ColumnDef,
@ -12,7 +12,6 @@ import {
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import { useLiveQuery } from 'dexie-react-hooks';
import * as Icons from 'lucide-react';
import { MoreHorizontal } from 'lucide-react';
import { useMemo, useState } from 'react';
@ -51,10 +50,10 @@ import {
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { db } from '@/lib/dexie-db';
import { type BreadcrumbItem } from '@/types';
import { type AutomationRule, getRuleActions } from '@/types/automation-rule';
import { getCategoryColorClasses } from '@/types/category';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { type Label } from '@/types/label';
const breadcrumbs: BreadcrumbItem[] = [
{
@ -63,7 +62,15 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
function AutomationRuleActions({
rule,
categories,
labels,
}: {
rule: AutomationRule;
categories: Category[];
labels: Label[];
}) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@ -71,7 +78,11 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0" aria-label="Actions">
<Button
variant="ghost"
className="h-8 w-8 p-0"
aria-label="Actions"
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
@ -92,6 +103,8 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
<EditAutomationRuleDialog
rule={rule}
categories={categories}
labels={labels}
open={editOpen}
onOpenChange={setEditOpen}
/>
@ -104,7 +117,15 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
);
}
function AutomationRuleRow({ row }: { row: Row<AutomationRule> }) {
function AutomationRuleRow({
row,
categories,
labels,
}: {
row: Row<AutomationRule>;
categories: Category[];
labels: Label[];
}) {
const rule = row.original;
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@ -148,6 +169,8 @@ function AutomationRuleRow({ row }: { row: Row<AutomationRule> }) {
<EditAutomationRuleDialog
rule={rule}
categories={categories}
labels={labels}
open={editOpen}
onOpenChange={setEditOpen}
/>
@ -162,8 +185,13 @@ function AutomationRuleRow({ row }: { row: Row<AutomationRule> }) {
export default function AutomationRules() {
const { isKeySet } = useEncryptionKey();
const rawRules =
useLiveQuery(() => db.automation_rules.toArray(), []) || [];
const { automationRules: rawRules } = usePage<{
automationRules: AutomationRule[];
}>().props;
// Get categories and labels from globally shared Inertia data
const categories = usePage().props.categories as Category[];
const labels = usePage().props.labels as Label[];
const rules = useMemo(
() =>
rawRules.map((rule) => ({
@ -276,7 +304,13 @@ export default function AutomationRules() {
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => <AutomationRuleActions rule={row.original} />,
cell: ({ row }) => (
<AutomationRuleActions
rule={row.original}
categories={categories}
labels={labels}
/>
),
},
];
@ -323,7 +357,11 @@ export default function AutomationRules() {
}
className="max-w-sm"
/>
<CreateAutomationRuleDialog disabled={!isKeySet} />
<CreateAutomationRuleDialog
categories={categories}
labels={labels}
disabled={!isKeySet}
/>
</div>
<div className="overflow-hidden rounded-md border">
@ -363,6 +401,8 @@ export default function AutomationRules() {
<AutomationRuleRow
key={row.id}
row={row}
categories={categories}
labels={labels}
/>
))
) : (

View File

@ -1,4 +1,4 @@
import { Head } from '@inertiajs/react';
import { Head, usePage } from '@inertiajs/react';
import {
Cell,
ColumnDef,
@ -12,7 +12,6 @@ import {
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import { useLiveQuery } from 'dexie-react-hooks';
import * as Icons from 'lucide-react';
import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
import { useState } from 'react';
@ -49,7 +48,6 @@ import {
} from '@/components/ui/table';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { db } from '@/lib/dexie-db';
import { type BreadcrumbItem } from '@/types';
import { type Category, getCategoryColorClasses } from '@/types/category';
@ -162,7 +160,7 @@ function CategoryRow({ row }: { row: Row<Category> }) {
}
export default function Categories() {
const categories = useLiveQuery(() => db.categories.toArray(), []) || [];
const { categories } = usePage<{ categories: Category[] }>().props;
const [sorting, setSorting] = useState<SortingState>([
{ id: 'name', desc: false },
]);

View File

@ -1,4 +1,4 @@
import { Head } from '@inertiajs/react';
import { Head, usePage } from '@inertiajs/react';
import {
Cell,
ColumnDef,
@ -12,7 +12,6 @@ import {
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import { useLiveQuery } from 'dexie-react-hooks';
import { ArrowUpDown, MoreHorizontal, Tag } from 'lucide-react';
import { useState } from 'react';
@ -48,7 +47,6 @@ import {
} from '@/components/ui/table';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { db } from '@/lib/dexie-db';
import { type BreadcrumbItem } from '@/types';
import { getLabelColorClasses, type Label } from '@/types/label';
@ -161,7 +159,7 @@ function LabelRow({ row }: { row: Row<Label> }) {
}
export default function Labels() {
const labels = useLiveQuery(() => db.labels.toArray(), []) || [];
const { labels } = usePage<{ labels: Label[] }>().props;
const [sorting, setSorting] = useState<SortingState>([
{ id: 'name', desc: false },
]);

View File

@ -25,7 +25,7 @@ import { type Account, type Bank } from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction';
import { Head, Link, router } from '@inertiajs/react';
import { Head, Link, router, usePage } from '@inertiajs/react';
import { parseISO } from 'date-fns';
import { useLiveQuery } from 'dexie-react-hooks';
import {
@ -97,6 +97,9 @@ export default function CategorizeTransactions({
banks,
}: Props) {
const { isKeySet } = useEncryptionKey();
const { automationRules: sharedAutomationRules } = usePage<{
automationRules: AutomationRule[];
}>().props;
const transactionIds = useLiveQuery(
async () => {
@ -125,19 +128,16 @@ export default function CategorizeTransactions({
const [encryptionKey, setEncryptionKey] = useState<CryptoKey | null>(null);
const commandInputRef = useRef<HTMLInputElement>(null);
const automationRules = useLiveQuery(
async () => {
const rules = await db.automation_rules.toArray();
return rules.map((rule) => ({
const automationRules = useMemo(
() =>
sharedAutomationRules.map((rule) => ({
...rule,
rules_json:
typeof rule.rules_json === 'string'
? JSON.parse(rule.rules_json)
: rule.rules_json,
})) as AutomationRule[];
},
[],
[],
})) as AutomationRule[],
[sharedAutomationRules],
);
useEffect(() => {

View File

@ -49,6 +49,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { TableCell, TableRow } from '@/components/ui/table';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useSyncContext } from '@/contexts/sync-context';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { decrypt, encrypt, importKey } from '@/lib/crypto';
import { consoleDebug } from '@/lib/debug';
@ -56,10 +57,10 @@ import { db } from '@/lib/dexie-db';
import { getStoredKey } from '@/lib/key-storage';
import { evaluateRules } from '@/lib/rule-engine';
import { appendNoteIfNotPresent, cn } from '@/lib/utils';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { transactionSyncService } from '@/services/transaction-sync';
import { type BreadcrumbItem } from '@/types';
import { type Account, type Bank } from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
import { type Category } from '@/types/category';
import { type Label } from '@/types/label';
import {
@ -79,6 +80,7 @@ interface Props {
accounts: Account[];
banks: Bank[];
labels: Label[];
automationRules: AutomationRule[];
}
const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility';
@ -357,8 +359,15 @@ export default function Transactions({
accounts,
banks,
labels: initialLabels,
automationRules,
}: Props) {
const { isKeySet } = useEncryptionKey();
const { sync } = useSyncContext();
// Sync transactions when page loads
useEffect(() => {
sync();
}, [sync]);
const transactionIds = useLiveQuery(
async () => {
@ -387,7 +396,7 @@ export default function Transactions({
const [filters, setFilters] = useState<Filters>(() =>
parseFiltersFromURL(),
);
const labels = useLiveQuery(() => db.labels.toArray(), [], initialLabels);
const labels = initialLabels;
const [editTransaction, setEditTransaction] =
useState<DecryptedTransaction | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
@ -816,7 +825,7 @@ export default function Transactions({
consoleDebug('✓ Encryption key found');
const key = await importKey(keyString);
const rules = await automationRuleSyncService.getAll();
const rules = automationRules;
consoleDebug(`Found ${rules.length} automation rules`);
if (rules.length === 0) {
@ -917,7 +926,14 @@ export default function Transactions({
consoleDebug('=== Re-evaluation complete ===');
}
},
[isKeySet, categories, accounts, banks, updateTransaction],
[
isKeySet,
categories,
accounts,
banks,
updateTransaction,
automationRules,
],
);
async function handleBulkReEvaluateRules() {
@ -944,7 +960,7 @@ export default function Transactions({
consoleDebug('✓ Encryption key found');
const key = await importKey(keyString);
const rules = await automationRuleSyncService.getAll();
const rules = automationRules;
consoleDebug(`Found ${rules.length} automation rules`);
if (rules.length === 0) {
@ -1211,6 +1227,9 @@ export default function Transactions({
setDeleteTransaction(null);
setIsBulkDeleteMode(false);
setRowSelection({});
// Sync to update IndexedDB
sync();
} catch (error) {
console.error('Failed to delete transaction:', error);
} finally {
@ -1282,6 +1301,9 @@ export default function Transactions({
setRowSelection({});
setIsSelectingAll(false);
// Sync to update IndexedDB
sync();
} catch (error) {
console.error('Failed to update transactions:', error);
toast.error('Failed to update transactions');
@ -1329,6 +1351,9 @@ export default function Transactions({
setDeleteTransaction(null);
setIsBulkDeleteMode(false);
setRowSelection({});
// Sync to update IndexedDB
sync();
} catch (error) {
console.error('Failed to delete transactions:', error);
} finally {
@ -1430,6 +1455,9 @@ export default function Transactions({
setRowSelection({});
setIsSelectingAll(false);
// Sync to update IndexedDB
sync();
} catch (error) {
console.error('Failed to update transactions with labels:', error);
toast.error('Failed to update transactions with labels');

View File

@ -1,129 +0,0 @@
import { db } from '@/lib/dexie-db';
import { SyncManager } from '@/lib/sync-manager';
import type { AccountBalance } from '@/types/account';
class AccountBalanceSyncService {
private syncManager: SyncManager;
constructor() {
this.syncManager = new SyncManager({
storeName: 'account_balances',
endpoint: '/api/sync/account-balances',
});
}
async sync() {
return await this.syncManager.sync();
}
async getAll(): Promise<AccountBalance[]> {
return await this.syncManager.getAll<AccountBalance>();
}
async getById(id: string): Promise<AccountBalance | null> {
return (await db.account_balances.get(id)) || null;
}
async getByAccountId(accountId: number): Promise<AccountBalance[]> {
try {
const allBalances = await this.getAll();
return allBalances.filter((b) => b.account_id === accountId);
} catch (error) {
console.warn('Failed to get balances from IndexedDB:', error);
return [];
}
}
async create(
data: Omit<AccountBalance, 'id' | 'created_at' | 'updated_at'>,
): Promise<AccountBalance> {
return await this.syncManager.createLocal<AccountBalance>(data);
}
async createMany(
balances: Omit<AccountBalance, 'id' | 'created_at' | 'updated_at'>[],
): Promise<AccountBalance[]> {
try {
const created: AccountBalance[] = [];
for (const data of balances) {
const result = await this.updateOrCreate(
data.account_id,
data.balance_date,
data.balance,
);
created.push(result);
}
return created;
} catch (error) {
console.error('Failed to create balances:', error);
throw new Error(
'Failed to save balances locally. Please refresh the page and try again.',
);
}
}
async update(id: string, data: Partial<AccountBalance>): Promise<void> {
const existing = await this.getById(id);
if (!existing) {
throw new Error('Balance not found');
}
return await this.syncManager.update<AccountBalance>(
id.toString(),
data,
);
}
async updateOrCreate(
accountId: number,
balanceDate: string,
balance: number,
): Promise<AccountBalance> {
try {
const existing = await db.account_balances
.where({ account_id: accountId, balance_date: balanceDate })
.first();
if (existing) {
const timestamp = new Date().toISOString();
const updated = {
...existing,
balance,
updated_at: timestamp,
};
await db.account_balances.put(updated);
await db.pending_changes.add({
store: 'account_balances',
operation: 'update',
data: {
id: existing.id,
account_id: accountId,
balance_date: balanceDate,
balance,
},
timestamp,
});
return updated;
} else {
return await this.create({
account_id: accountId,
balance_date: balanceDate,
balance,
});
}
} catch (error) {
console.error('Failed to update or create balance:', error);
throw new Error('Failed to save balance. Please try again.');
}
}
async delete(id: string): Promise<void> {
return await this.syncManager.delete(id);
}
}
export const accountBalanceSyncService = new AccountBalanceSyncService();

View File

@ -1,50 +0,0 @@
import { SyncManager } from '@/lib/sync-manager';
import type { Account } from '@/types/account';
import type { UUID } from '@/types/uuid';
class AccountSyncService {
private syncManager: SyncManager;
constructor() {
this.syncManager = new SyncManager({
storeName: 'accounts',
endpoint: '/api/sync/accounts',
});
}
async sync() {
return await this.syncManager.sync();
}
async getAll(): Promise<Account[]> {
return await this.syncManager.getAll<Account>();
}
async getById(id: UUID): Promise<Account | null> {
return await this.syncManager.getById<Account>(id);
}
async create(data: Omit<Account, 'id'>): Promise<Account> {
return await this.syncManager.createLocal<Account>(
data as Omit<Account, 'id' | 'created_at' | 'updated_at'>,
);
}
async update(id: UUID, data: Partial<Account>): Promise<void> {
await this.syncManager.updateLocal<Account>(id, data);
}
async delete(id: UUID): Promise<void> {
await this.syncManager.deleteLocal(id);
}
async getLastSyncTime(): Promise<string | null> {
return await this.syncManager.getLastSyncTime();
}
isSyncing(): boolean {
return this.syncManager.isSyncing();
}
}
export const accountSyncService = new AccountSyncService();

View File

@ -1,71 +0,0 @@
import { SyncManager } from '@/lib/sync-manager';
import type { AutomationRule } from '@/types/automation-rule';
import type { UUID } from '@/types/uuid';
class AutomationRuleSyncService {
private syncManager: SyncManager;
constructor() {
this.syncManager = new SyncManager({
storeName: 'automation_rules',
endpoint: '/api/sync/automation-rules',
});
}
async sync() {
return await this.syncManager.sync();
}
async getAll(): Promise<AutomationRule[]> {
const rules = await this.syncManager.getAll<AutomationRule>();
return rules.map((rule) => ({
...rule,
rules_json:
typeof rule.rules_json === 'string'
? JSON.parse(rule.rules_json)
: rule.rules_json,
}));
}
async getById(id: UUID): Promise<AutomationRule | null> {
const rule = await this.syncManager.getById<AutomationRule>(id);
if (!rule) {
return null;
}
return {
...rule,
rules_json:
typeof rule.rules_json === 'string'
? JSON.parse(rule.rules_json)
: rule.rules_json,
};
}
async create(data: Omit<AutomationRule, 'id'>): Promise<AutomationRule> {
return await this.syncManager.createLocal<AutomationRule>(
data as Omit<AutomationRule, 'id'> & {
id?: UUID;
created_at?: string;
updated_at?: string;
},
);
}
async update(id: UUID, data: Partial<AutomationRule>): Promise<void> {
await this.syncManager.updateLocal<AutomationRule>(id, data);
}
async delete(id: UUID): Promise<void> {
await this.syncManager.deleteLocal(id);
}
async getLastSyncTime(): Promise<string | null> {
return await this.syncManager.getLastSyncTime();
}
isSyncing(): boolean {
return this.syncManager.isSyncing();
}
}
export const automationRuleSyncService = new AutomationRuleSyncService();

View File

@ -1,44 +0,0 @@
import { SyncManager } from '@/lib/sync-manager';
import type { Bank } from '@/types/account';
import type { UUID } from '@/types/uuid';
class BankSyncService {
private syncManager: SyncManager;
constructor() {
this.syncManager = new SyncManager({
storeName: 'banks',
endpoint: '/api/sync/banks',
});
}
async sync() {
return await this.syncManager.sync();
}
async getAll(): Promise<Bank[]> {
return await this.syncManager.getAll<Bank>();
}
async getById(id: UUID): Promise<Bank | null> {
return await this.syncManager.getById<Bank>(id);
}
async search(query: string): Promise<Bank[]> {
const allBanks = await this.getAll();
const lowerQuery = query.toLowerCase();
return allBanks.filter((bank) =>
bank.name.toLowerCase().includes(lowerQuery),
);
}
async getLastSyncTime(): Promise<string | null> {
return await this.syncManager.getLastSyncTime();
}
isSyncing(): boolean {
return this.syncManager.isSyncing();
}
}
export const bankSyncService = new BankSyncService();

View File

@ -1,50 +0,0 @@
import { SyncManager } from '@/lib/sync-manager';
import type { Category } from '@/types/category';
import type { UUID } from '@/types/uuid';
class CategorySyncService {
private syncManager: SyncManager;
constructor() {
this.syncManager = new SyncManager({
storeName: 'categories',
endpoint: '/api/sync/categories',
});
}
async sync() {
return await this.syncManager.sync();
}
async getAll(): Promise<Category[]> {
return await this.syncManager.getAll<Category>();
}
async getById(id: UUID): Promise<Category | null> {
return await this.syncManager.getById<Category>(id);
}
async create(data: Omit<Category, 'id'>): Promise<Category> {
return await this.syncManager.createLocal<Category>(
data as Omit<Category, 'id' | 'created_at' | 'updated_at'>,
);
}
async update(id: UUID, data: Partial<Category>): Promise<void> {
await this.syncManager.updateLocal<Category>(id, data);
}
async delete(id: UUID): Promise<void> {
await this.syncManager.deleteLocal(id);
}
async getLastSyncTime(): Promise<string | null> {
return await this.syncManager.getLastSyncTime();
}
isSyncing(): boolean {
return this.syncManager.isSyncing();
}
}
export const categorySyncService = new CategorySyncService();

View File

@ -1,102 +0,0 @@
import { store } from '@/actions/App/Http/Controllers/Settings/LabelController';
import { SyncManager } from '@/lib/sync-manager';
import type { Label } from '@/types/label';
import type { UUID } from '@/types/uuid';
class LabelSyncService {
private syncManager: SyncManager;
constructor() {
this.syncManager = new SyncManager({
storeName: 'labels',
endpoint: '/api/sync/labels',
});
}
async sync() {
return await this.syncManager.sync();
}
async getAll(): Promise<Label[]> {
return await this.syncManager.getAll<Label>();
}
async getById(id: UUID): Promise<Label | null> {
return await this.syncManager.getById<Label>(id);
}
async create(data: { name: string; color: string }): Promise<Label> {
const csrfToken = decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
const response = await fetch(store().url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
console.error('Failed to create label:', errorData);
throw new Error(errorData?.message || 'Failed to create label');
}
const result = await response.json();
const label = result.data as Label;
// Store the label in IndexedDB
const { db } = await import('@/lib/dexie-db');
await db.labels.put(label);
return label;
}
async update(id: UUID, data: Partial<Label>): Promise<void> {
await this.syncManager.updateLocal<Label>(id, data);
}
async delete(id: UUID): Promise<void> {
await this.syncManager.deleteLocal(id);
}
async getLastSyncTime(): Promise<string | null> {
return await this.syncManager.getLastSyncTime();
}
isSyncing(): boolean {
return this.syncManager.isSyncing();
}
async findOrCreate(
name: string,
color: string = 'blue',
): Promise<Label | null> {
const labels = await this.getAll();
const existing = labels.find(
(l) => l.name.toLowerCase() === name.toLowerCase(),
);
if (existing) {
return existing;
}
try {
return await this.create({ name, color });
} catch (error) {
console.error('Failed to create label:', error);
return null;
}
}
}
export const labelSyncService = new LabelSyncService();

View File

@ -1,26 +1,9 @@
import { encrypt, importKey } from '@/lib/crypto';
import { db } from '@/lib/dexie-db';
import { getStoredKey } from '@/lib/key-storage';
import { SyncManager } from '@/lib/sync-manager';
import { TransactionSyncManager } from '@/lib/sync-manager';
import type { Transaction } from '@/types/transaction';
import type { UUID } from '@/types/uuid';
import { uuidv7 } from 'uuidv7';
export interface Transaction {
id: UUID;
user_id: UUID;
account_id: UUID;
category_id: UUID | null;
description: string;
description_iv: string;
transaction_date: string;
amount: string;
currency_code: string;
notes: string | null;
notes_iv: string | null;
label_ids?: UUID[];
created_at: string;
updated_at: string;
}
import axios from 'axios';
interface TransactionUpdateData extends Partial<Transaction> {
label_ids?: string[];
@ -37,15 +20,22 @@ interface TransactionFilters {
searchText?: string;
}
function getCsrfToken(): string {
return decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
}
class TransactionSyncService {
private syncManager: SyncManager;
private syncManager: TransactionSyncManager;
constructor() {
this.syncManager = new SyncManager({
storeName: 'transactions',
this.syncManager = new TransactionSyncManager({
endpoint: '/api/sync/transactions',
transformFromServer: (data) => {
// Extract label_ids from labels array if present
const label_ids = data.labels?.map((l: { id: string }) => l.id);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { labels, ...rest } = data;
@ -66,74 +56,51 @@ class TransactionSyncService {
}
async getAll(): Promise<Transaction[]> {
return await this.syncManager.getAll<Transaction>();
return await this.syncManager.getAll();
}
async getById(id: UUID): Promise<Transaction | null> {
return (await db.transactions.get(id)) || null;
return await this.syncManager.getById(id);
}
async getByAccountId(accountId: UUID): Promise<Transaction[]> {
try {
const allTransactions = await this.getAll();
return allTransactions.filter((t) => t.account_id === accountId);
} catch (error) {
console.warn('Failed to get transactions from IndexedDB:', error);
return [];
}
return await this.syncManager.getByAccountId(accountId);
}
async create(
data: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>,
): Promise<Transaction> {
return await this.syncManager.createLocal<Transaction>(
data as Omit<Transaction, 'id' | 'created_at' | 'updated_at'> & {
id?: number;
created_at?: string;
updated_at?: string;
},
);
const response = await axios.post('/api/sync/transactions', data);
const serverData = response.data.data;
const label_ids = serverData.labels?.map((l: { id: string }) => l.id);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { labels, ...rest } = serverData;
return {
...rest,
transaction_date: String(serverData.transaction_date).slice(0, 10),
label_ids: label_ids || [],
} as Transaction;
}
async createMany(
transactions: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>[],
): Promise<Transaction[]> {
try {
const timestamp = new Date().toISOString();
const created: Transaction[] = [];
for (const data of transactions) {
const record = {
...data,
id: uuidv7(),
created_at: timestamp,
updated_at: timestamp,
} as Transaction;
await db.transactions.put(record);
await db.pending_changes.add({
store: 'transactions',
operation: 'create',
data: record,
timestamp,
});
created.push(record);
const transaction = await this.create(data);
created.push(transaction);
}
return created;
} catch (error) {
console.error('Failed to create transactions in IndexedDB:', error);
throw new Error(
'Failed to save transactions locally. Please refresh the page and try again.',
);
}
}
async update(
id: string,
data: TransactionUpdateData,
): Promise<Transaction | void> {
): Promise<Transaction> {
const existing = await this.getById(id);
if (!existing) {
@ -141,16 +108,6 @@ class TransactionSyncService {
}
const { label_ids, ...transactionData } = data;
const timestamp = new Date().toISOString();
// If label_ids are provided, we need to sync with the server immediately
if (label_ids !== undefined) {
const csrfToken = decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
const response = await fetch(`/api/sync/transactions/${id}`, {
method: 'PATCH',
@ -158,7 +115,7 @@ class TransactionSyncService {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': csrfToken,
'X-XSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({
@ -175,67 +132,32 @@ class TransactionSyncService {
const result = await response.json();
const serverData = result.data;
// Extract label_ids from labels array
const serverLabelIds = serverData.labels?.map(
(l: { id: string }) => l.id,
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { labels: _labels, ...restServerData } = serverData;
const updatedTransaction: Transaction = {
return {
...restServerData,
transaction_date: String(serverData.transaction_date).slice(
0,
10,
),
transaction_date: String(serverData.transaction_date).slice(0, 10),
label_ids: serverLabelIds || [],
};
// Update local storage with transformed data (label_ids instead of labels)
await db.transactions.put(updatedTransaction);
return updatedTransaction;
}
// No label_ids, use the normal offline-first approach
const updated = {
...existing,
...transactionData,
updated_at: timestamp,
};
await db.transactions.put(updated);
await db.pending_changes.add({
store: 'transactions',
operation: 'update',
data: updated,
timestamp,
});
} as Transaction;
}
async updateMany(
ids: string[],
data: TransactionUpdateData,
): Promise<void> {
const timestamp = new Date().toISOString();
const { label_ids, ...transactionData } = data;
if (label_ids !== undefined) {
try {
const csrfToken = decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
const response = await fetch('/transactions/bulk', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': csrfToken,
'X-XSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({
@ -248,75 +170,6 @@ class TransactionSyncService {
if (!response.ok) {
throw new Error('Failed to bulk update transactions');
}
// Update IndexedDB with the label_ids after successful API call
// Use bulkPut to update all transactions at once (single operation)
const updates = [];
for (const id of ids) {
const existing = await this.getById(id);
if (existing) {
// Merge labels: if label_ids is empty, clear all; otherwise merge with existing
const mergedLabelIds =
label_ids.length === 0
? []
: Array.from(
new Set([
...(existing.label_ids || []),
...label_ids,
]),
);
updates.push({
...existing,
...transactionData,
label_ids: mergedLabelIds,
updated_at: timestamp,
});
}
}
if (updates.length > 0) {
await db.transactions.bulkPut(updates);
}
return;
} catch (error) {
console.error('Failed to update transactions via API:', error);
throw error;
}
}
// Batch update for non-label updates (e.g., category changes)
const updates = [];
const pendingChanges = [];
for (const id of ids) {
const existing = await this.getById(id);
if (!existing) {
console.warn(`Transaction ${id} not found, skipping`);
continue;
}
const updated = {
...existing,
...transactionData,
updated_at: timestamp,
};
updates.push(updated);
pendingChanges.push({
store: 'transactions',
operation: 'update',
data: updated,
timestamp,
});
}
if (updates.length > 0) {
await db.transactions.bulkPut(updates);
await db.pending_changes.bulkAdd(pendingChanges);
}
}
async updateByFilters(
@ -350,21 +203,13 @@ class TransactionSyncService {
requestFilters.label_ids = filters.labelIds;
}
try {
const csrfToken = decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
const response = await fetch('/transactions/bulk', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': csrfToken,
'X-XSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({
@ -375,190 +220,28 @@ class TransactionSyncService {
});
if (!response.ok) {
throw new Error(
'Failed to bulk update transactions by filters',
);
throw new Error('Failed to bulk update transactions by filters');
}
const result = await response.json();
// Update IndexedDB locally instead of doing a full sync
// Get all transactions that match the filters and update them
const allTransactions = await db.transactions.toArray();
const updates = [];
const timestamp = new Date().toISOString();
for (const transaction of allTransactions) {
// Apply the same filters as the backend
let matches = true;
if (
filters.dateFrom &&
transaction.transaction_date <
filters.dateFrom.toISOString().split('T')[0]
) {
matches = false;
}
if (
filters.dateTo &&
transaction.transaction_date >
filters.dateTo.toISOString().split('T')[0]
) {
matches = false;
}
if (
filters.amountMin !== null &&
filters.amountMin !== undefined &&
parseFloat(transaction.amount) < filters.amountMin * 100
) {
matches = false;
}
if (
filters.amountMax !== null &&
filters.amountMax !== undefined &&
parseFloat(transaction.amount) > filters.amountMax * 100
) {
matches = false;
}
if (
filters.categoryIds &&
filters.categoryIds.length > 0 &&
!filters.categoryIds.includes(
transaction.category_id as number,
)
) {
matches = false;
}
if (
filters.accountIds &&
filters.accountIds.length > 0 &&
!filters.accountIds.includes(transaction.account_id)
) {
matches = false;
}
if (filters.labelIds && filters.labelIds.length > 0) {
const hasMatchingLabel = transaction.label_ids?.some((id) =>
filters.labelIds?.includes(id),
);
if (!hasMatchingLabel) {
matches = false;
}
}
if (matches) {
const updated = {
...transaction,
...transactionData,
label_ids:
label_ids !== undefined
? label_ids.length === 0
? []
: Array.from(
new Set([
...(transaction.label_ids || []),
...label_ids,
]),
)
: transaction.label_ids,
updated_at: timestamp,
};
updates.push(updated);
}
}
if (updates.length > 0) {
await db.transactions.bulkPut(updates);
}
return result.count || 0;
} catch (error) {
console.error(
'Failed to update transactions by filters via API:',
error,
);
throw error;
}
}
async delete(id: string): Promise<void> {
const transaction = await this.getById(id);
if (!transaction) {
throw new Error('Transaction not found');
}
const timestamp = new Date().toISOString();
await db.transactions.delete(transaction.id);
await db.pending_changes.add({
store: 'transactions',
operation: 'delete',
data: transaction,
timestamp,
});
await axios.delete(`/api/sync/transactions/${id}`);
}
async updateManyIndividual(
updates: Array<{ id: string; data: TransactionUpdateData }>,
): Promise<void> {
const timestamp = new Date().toISOString();
const dbUpdates: Transaction[] = [];
const pendingChanges: Array<{
store: string;
operation: string;
data: Transaction;
timestamp: string;
}> = [];
for (const { id, data } of updates) {
const existing = await this.getById(id);
if (!existing) {
console.warn(`Transaction ${id} not found, skipping`);
continue;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { label_ids, ...transactionData } = data;
const updated = {
...existing,
...transactionData,
updated_at: timestamp,
} as Transaction;
dbUpdates.push(updated);
pendingChanges.push({
store: 'transactions',
operation: 'update',
data: updated,
timestamp,
});
}
if (dbUpdates.length > 0) {
await db.transactions.bulkPut(dbUpdates);
await db.pending_changes.bulkAdd(pendingChanges);
await this.update(id, data);
}
}
async deleteMany(ids: string[]): Promise<void> {
const timestamp = new Date().toISOString();
for (const id of ids) {
const transaction = await this.getById(id);
if (!transaction) {
console.warn(`Transaction ${id} not found, skipping`);
continue;
}
await db.transactions.delete(transaction.id);
await db.pending_changes.add({
store: 'transactions',
operation: 'delete',
data: transaction,
timestamp,
});
await this.delete(id);
}
}
@ -588,10 +271,6 @@ class TransactionSyncService {
return txDate >= minDate && txDate <= maxDate;
});
console.log(
`Checking duplicates for ${transactions.length} transactions. Found ${transactionsInRange.length} existing transactions between ${minDate} and ${maxDate}`,
);
const keyString = getStoredKey();
if (!keyString) {
console.warn('No encryption key found for duplicate check');
@ -617,12 +296,7 @@ class TransactionSyncService {
.trim()
.replace(/\s+/g, ' '),
};
} catch (error) {
console.error(
'Failed to decrypt transaction:',
t.id,
error,
);
} catch {
return null;
}
}),
@ -632,11 +306,7 @@ class TransactionSyncService {
(t) => t !== null,
);
console.log(
`Successfully decrypted ${validDecryptedTransactions.length} transactions`,
);
const results = transactions.map((importingTx) => {
return transactions.map((importingTx) => {
const normalizedDescription = importingTx.description
.toLowerCase()
.trim()
@ -651,13 +321,6 @@ class TransactionSyncService {
existing.description === normalizedDescription,
);
});
const duplicateCount = results.filter((r) => r).length;
console.log(
`Found ${duplicateCount} duplicates out of ${transactions.length} transactions`,
);
return results;
} catch (error) {
console.warn(
'Duplicate check failed, assuming no duplicates:',
@ -686,6 +349,10 @@ class TransactionSyncService {
isSyncing(): boolean {
return this.syncManager.isSyncing();
}
async clearAll(): Promise<void> {
await this.syncManager.clearAll();
}
}
export const transactionSyncService = new TransactionSyncService();

View File

@ -3,13 +3,8 @@
use App\Http\Controllers\AccountBalanceController;
use App\Http\Controllers\Api\CashflowAnalyticsController;
use App\Http\Controllers\Api\DashboardAnalyticsController;
use App\Http\Controllers\Api\ImportDataController;
use App\Http\Controllers\EncryptionController;
use App\Http\Controllers\Sync\AccountBalanceSyncController;
use App\Http\Controllers\Sync\AccountSyncController;
use App\Http\Controllers\Sync\AutomationRuleSyncController;
use App\Http\Controllers\Sync\BankSyncController;
use App\Http\Controllers\Sync\CategorySyncController;
use App\Http\Controllers\Sync\LabelSyncController;
use App\Http\Controllers\Sync\TransactionSyncController;
use Illuminate\Support\Facades\Route;
@ -18,27 +13,21 @@ Route::middleware(['web', 'auth'])->group(function () {
Route::post('encryption/setup', [EncryptionController::class, 'setup']);
Route::get('encryption/message', [EncryptionController::class, 'getMessage']);
// Sync
Route::prefix('sync')->group(function () {
Route::get('categories', [CategorySyncController::class, 'index']);
Route::get('labels', [LabelSyncController::class, 'index']);
Route::get('accounts', [AccountSyncController::class, 'index']);
Route::get('banks', [BankSyncController::class, 'index']);
Route::get('automation-rules', [AutomationRuleSyncController::class, 'index']);
// Import Data (for import drawers)
Route::get('import/data', [ImportDataController::class, 'index']);
// Transaction Sync (for IndexedDB client-side search)
Route::prefix('sync')->group(function () {
Route::get('transactions', [TransactionSyncController::class, 'index']);
Route::post('transactions', [TransactionSyncController::class, 'store']);
Route::patch('transactions/{transaction}', [TransactionSyncController::class, 'update']);
Route::delete('transactions/{transaction}', [TransactionSyncController::class, 'destroy']);
Route::get('account-balances', [AccountBalanceSyncController::class, 'index']);
Route::post('account-balances', [AccountBalanceSyncController::class, 'store']);
Route::patch('account-balances/{accountBalance}', [AccountBalanceSyncController::class, 'update']);
});
// Account Balances
Route::put('accounts/{account}/balance/current', [AccountBalanceController::class, 'updateCurrent'])->name('api.accounts.balance.update-current');
Route::get('accounts/{account}/balances', [AccountBalanceController::class, 'index'])->name('api.accounts.balances.index');
Route::post('accounts/{account}/balances', [AccountBalanceController::class, 'store'])->name('api.accounts.balances.store');
Route::delete('accounts/{account}/balances/{accountBalance}', [AccountBalanceController::class, 'destroy'])->name('api.accounts.balances.destroy');
// Dashboard Analytics

View File

@ -32,6 +32,7 @@ Route::middleware('auth')->group(function () {
Route::patch('settings/accounts/{account}', [AccountController::class, 'update'])->name('accounts.update');
Route::delete('settings/accounts/{account}', [AccountController::class, 'destroy'])->name('accounts.destroy');
Route::get('settings/banks', [BankController::class, 'index'])->name('banks.index');
Route::post('settings/banks', [BankController::class, 'store'])->name('banks.store');
Route::get('settings/categories', [CategoryController::class, 'index'])->name('categories.index');

View File

@ -33,6 +33,7 @@ it('shows existing accounts in list', function () {
actingAs($user);
$page = visit('/settings/accounts');
$this->setupEncryptionKey($page);
$page->assertSee('Bank accounts')
->waitForText('Test Bank')
@ -125,6 +126,7 @@ it('can filter accounts by name', function () {
actingAs($user);
$page = visit('/settings/accounts');
$this->setupEncryptionKey($page);
$page->assertSee('Bank accounts')
->waitForText('Test Bank')
@ -140,12 +142,13 @@ it('can edit an existing account via dropdown menu', function () {
actingAs($user);
$page = visit('/settings/accounts');
$page->wait(1);
$this->setupEncryptionKey($page);
// Create account via UI to ensure it syncs to IndexedDB
createAccountViaUI($page, 'Old Account Name', 'Edit Bank', 'Checking', 'USD');
$page->navigate('/settings/accounts')->wait(1);
$page->navigate('/settings/accounts')->wait(3);
$page->assertSee('Bank accounts')
->click('button[aria-label="Open menu"]')
@ -157,7 +160,7 @@ it('can edit an existing account via dropdown menu', function () {
->click('button[type="submit"]:has-text("Update")')
->wait(2);
$page->navigate('/settings/accounts')->wait(1);
$page->navigate('/settings/accounts')->wait(3);
$page->assertSee('Updated Account Name')
->assertNoJavascriptErrors();
@ -170,12 +173,13 @@ it('can delete an account via dropdown menu', function () {
actingAs($user);
$page = visit('/settings/accounts');
$page->wait(1);
$this->setupEncryptionKey($page);
// Create account via UI to ensure it syncs to IndexedDB
createAccountViaUI($page, 'Account To Delete', 'Delete Bank', 'Checking', 'USD');
$page->navigate('/settings/accounts')->wait(1);
$page->navigate('/settings/accounts')->wait(3);
$page->assertSee('Bank accounts')
->assertSee('Account To Delete')
@ -189,7 +193,7 @@ it('can delete an account via dropdown menu', function () {
->click('button[type="submit"]:has-text("Delete")')
->wait(2);
$page->navigate('/settings/accounts')->wait(1);
$page->navigate('/settings/accounts')->wait(3);
$page->assertDontSee('Account To Delete')
->assertNoJavascriptErrors();

View File

@ -1,291 +0,0 @@
<?php
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\User;
it('can fetch user account balances', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$balances = AccountBalance::factory()->count(3)->for($account)->create();
$response = $this->actingAs($user)->getJson('/api/sync/account-balances');
$response->assertSuccessful()
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => [
'id',
'account_id',
'balance_date',
'balance',
'created_at',
'updated_at',
],
],
]);
});
it('only returns user own account balances', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$account = Account::factory()->for($user)->create();
$otherAccount = Account::factory()->for($otherUser)->create();
AccountBalance::factory()->for($account)->create();
AccountBalance::factory()->for($otherAccount)->create();
$response = $this->actingAs($user)->getJson('/api/sync/account-balances');
$response->assertSuccessful()
->assertJsonCount(1, 'data');
});
it('can filter balances by updated_at', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$oldBalance = AccountBalance::factory()->for($account)->create([
'updated_at' => now()->subDays(2),
]);
$newBalance = AccountBalance::factory()->for($account)->create([
'updated_at' => now(),
]);
$response = $this->actingAs($user)->getJson('/api/sync/account-balances?since='.now()->subDay()->toISOString());
$response->assertSuccessful()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $newBalance->id);
});
it('can create an account balance', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$balanceData = [
'account_id' => $account->id,
'balance_date' => now()->toDateString(),
'balance' => 134566,
];
$response = $this->actingAs($user)->postJson('/api/sync/account-balances', $balanceData);
$response->assertCreated()
->assertJsonStructure([
'data' => [
'id',
'account_id',
'balance_date',
'balance',
'created_at',
'updated_at',
],
]);
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => now()->toDateString(),
'balance' => 134566,
]);
});
it('can create an account balance with an ID', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$id = (string) \Illuminate\Support\Str::uuid7();
$balanceData = [
'id' => $id,
'account_id' => $account->id,
'balance_date' => now()->toDateString(),
'balance' => 250000,
];
$response = $this->actingAs($user)->postJson('/api/sync/account-balances', $balanceData);
$response->assertCreated()
->assertJsonPath('data.id', $id);
$this->assertDatabaseHas('account_balances', [
'id' => $id,
'account_id' => $account->id,
]);
});
it('validates required fields when creating a balance', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/sync/account-balances', []);
$response->assertUnprocessable()
->assertJsonValidationErrors(['account_id', 'balance_date', 'balance']);
});
it('can update an account balance', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$balance = AccountBalance::factory()->for($account)->create([
'balance' => 100000,
]);
$updateData = [
'account_id' => $account->id,
'balance_date' => $balance->balance_date->toDateString(),
'balance' => 200000,
];
$response = $this->actingAs($user)->patchJson("/api/sync/account-balances/{$balance->id}", $updateData);
$response->assertSuccessful()
->assertJsonPath('data.balance', 200000);
$this->assertDatabaseHas('account_balances', [
'id' => $balance->id,
'balance' => 200000,
]);
});
it('cannot update another user account balance', function () {
$user = User::factory()->create();
$userAccount = Account::factory()->for($user)->create();
$otherUser = User::factory()->create();
$otherAccount = Account::factory()->for($otherUser)->create();
$balance = AccountBalance::factory()->for($otherAccount)->create();
$updateData = [
'account_id' => $userAccount->id,
'balance_date' => $balance->balance_date->toDateString(),
'balance' => 999999,
];
$response = $this->actingAs($user)->patchJson("/api/sync/account-balances/{$balance->id}", $updateData);
$response->assertForbidden();
$this->assertDatabaseHas('account_balances', [
'id' => $balance->id,
'account_id' => $otherAccount->id,
]);
});
it('updates existing balance when creating with duplicate account_id and balance_date', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$date = '2025-01-15';
$initialBalance = AccountBalance::factory()->for($account)->create([
'balance_date' => $date,
'balance' => 100000,
]);
$balanceData = [
'account_id' => $account->id,
'balance_date' => $date,
'balance' => 250000,
];
$response = $this->actingAs($user)->postJson('/api/sync/account-balances', $balanceData);
$response->assertOk()
->assertJsonPath('data.id', $initialBalance->id)
->assertJsonPath('data.balance', 250000);
expect(AccountBalance::where('account_id', $account->id)
->where('balance_date', $date)
->count())->toBe(1);
});
it('can update balance by adding transaction amount to existing balance', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$date = now()->toDateString();
// Create an initial balance
$initialBalance = AccountBalance::factory()->for($account)->create([
'balance_date' => $date,
'balance' => 100000, // $1,000.00 in cents
]);
// Simulate adding a transaction amount (e.g., +$50.00 = 5000 cents)
$transactionAmount = 5000;
$newBalance = $initialBalance->balance + $transactionAmount;
$balanceData = [
'account_id' => $account->id,
'balance_date' => $date,
'balance' => $newBalance, // 105000 cents = $1,050.00
];
$response = $this->actingAs($user)->postJson('/api/sync/account-balances', $balanceData);
$response->assertOk()
->assertJsonPath('data.balance', 105000);
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => $date,
'balance' => 105000,
]);
});
it('can create new balance with transaction amount when no previous balance exists', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$date = now()->toDateString();
// Simulate starting from 0 and adding a transaction amount
$transactionAmount = -7500; // expense of $75.00
$balanceData = [
'account_id' => $account->id,
'balance_date' => $date,
'balance' => $transactionAmount, // -$75.00
];
$response = $this->actingAs($user)->postJson('/api/sync/account-balances', $balanceData);
$response->assertCreated()
->assertJsonPath('data.balance', -7500);
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => $date,
'balance' => -7500,
]);
});
it('can subtract expense amount from existing balance', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$date = now()->toDateString();
// Create an initial balance
$initialBalance = AccountBalance::factory()->for($account)->create([
'balance_date' => $date,
'balance' => 500000, // $5,000.00 in cents
]);
// Simulate adding an expense transaction (-$120.50 = -12050 cents)
$transactionAmount = -12050;
$newBalance = $initialBalance->balance + $transactionAmount;
$balanceData = [
'account_id' => $account->id,
'balance_date' => $date,
'balance' => $newBalance, // 487950 cents = $4,879.50
];
$response = $this->actingAs($user)->postJson('/api/sync/account-balances', $balanceData);
$response->assertOk()
->assertJsonPath('data.balance', 487950);
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => $date,
'balance' => 487950,
]);
});

View File

@ -12,9 +12,10 @@ test('user can view their automation rules', function () {
$response = $this->actingAs($user)->get(route('automation-rules.index'));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
$response->assertInertia(
fn ($page) => $page
->component('settings/automation-rules')
->has('rules', 1)
->has('automationRules', 1)
);
});
@ -196,11 +197,12 @@ test('rules are ordered by priority', function () {
$response = $this->actingAs($user)->get(route('automation-rules.index'));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->has('rules', 3)
->where('rules.0.priority', 10)
->where('rules.1.priority', 20)
->where('rules.2.priority', 30)
$response->assertInertia(
fn ($page) => $page
->has('automationRules', 3)
->where('automationRules.0.priority', 10)
->where('automationRules.1.priority', 20)
->where('automationRules.2.priority', 30)
);
});

View File

@ -2,10 +2,7 @@
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\AutomationRule;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
@ -424,119 +421,6 @@ describe('Cross-Resource IDOR Protection', function () {
});
describe('Sync Endpoints IDOR Protection', function () {
describe('AccountBalanceSyncController', function () {
it('cannot list account balances from another user account', function () {
$attacker = User::factory()->create();
$attackerAccount = Account::factory()->for($attacker)->create();
$attackerBalance = AccountBalance::factory()->for($attackerAccount)->create();
$victim = User::factory()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimBalances = AccountBalance::factory()->for($victimAccount)->count(3)->create();
actingAs($attacker);
$response = $this->getJson('/api/sync/account-balances');
$response->assertSuccessful();
$balances = $response->json('data');
expect($balances)->toBeArray();
expect(count($balances))->toBe(1);
expect($balances[0]['id'])->toBe($attackerBalance->id);
expect($balances[0]['id'])->not->toBeIn($victimBalances->pluck('id')->toArray());
});
it('cannot list account balances from another user even with since parameter', function () {
$attacker = User::factory()->create();
$attackerAccount = Account::factory()->for($attacker)->create();
$attackerBalance = AccountBalance::factory()->for($attackerAccount)->create([
'updated_at' => now()->subDay(),
]);
$victim = User::factory()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimBalance = AccountBalance::factory()->for($victimAccount)->create([
'updated_at' => now(),
]);
actingAs($attacker);
$response = $this->getJson('/api/sync/account-balances?since='.now()->subDays(2)->toISOString());
$response->assertSuccessful();
$balances = $response->json('data');
expect($balances)->toBeArray();
$balanceIds = collect($balances)->pluck('id')->toArray();
expect($balanceIds)->toContain($attackerBalance->id);
expect($balanceIds)->not->toContain($victimBalance->id);
});
it('cannot create account balance for another user account', function () {
$attacker = User::factory()->create();
$victim = User::factory()->create();
$victimAccount = Account::factory()->for($victim)->create();
actingAs($attacker);
$response = $this->postJson('/api/sync/account-balances', [
'account_id' => $victimAccount->id,
'balance_date' => now()->format('Y-m-d'),
'balance' => 9999999,
]);
$response->assertUnprocessable();
expect($response->json('errors.account_id'))->toBeArray();
$this->assertDatabaseMissing('account_balances', [
'account_id' => $victimAccount->id,
'balance' => 9999999,
]);
});
it('cannot update account balance from another user account', function () {
$attacker = User::factory()->create();
$attackerAccount = Account::factory()->for($attacker)->create();
$victim = User::factory()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimBalance = AccountBalance::factory()->for($victimAccount)->create([
'balance' => 1000000,
]);
actingAs($attacker);
$response = $this->patchJson("/api/sync/account-balances/{$victimBalance->id}", [
'account_id' => $attackerAccount->id,
'balance_date' => $victimBalance->balance_date->format('Y-m-d'),
'balance' => 9999999,
]);
$response->assertForbidden();
$this->assertDatabaseHas('account_balances', [
'id' => $victimBalance->id,
'balance' => 1000000,
'account_id' => $victimAccount->id,
]);
});
});
describe('AccountSyncController', function () {
it('cannot list accounts from another user', function () {
$attacker = User::factory()->create();
$victim = User::factory()->create();
Account::factory()->for($victim)->count(3)->create();
actingAs($attacker);
$response = $this->getJson('/api/sync/accounts');
$response->assertSuccessful();
$accounts = $response->json('data');
expect($accounts)->toBeArray();
expect(count($accounts))->toBe(0);
});
});
describe('TransactionSyncController', function () {
it('cannot list transactions from another user', function () {
$attacker = User::factory()->onboarded()->create();
@ -735,75 +619,4 @@ describe('Sync Endpoints IDOR Protection', function () {
]);
});
});
describe('CategorySyncController', function () {
it('cannot list categories from another user', function () {
$attacker = User::factory()->create();
$victim = User::factory()->create();
Category::factory()->for($victim)->count(3)->create();
actingAs($attacker);
$response = $this->getJson('/api/sync/categories');
$response->assertSuccessful();
$categories = $response->json('data');
expect($categories)->toBeArray();
expect(count($categories))->toBe(0);
});
});
describe('LabelSyncController', function () {
it('cannot list labels from another user', function () {
$attacker = User::factory()->create();
$victim = User::factory()->create();
Label::factory()->for($victim)->count(3)->create();
actingAs($attacker);
$response = $this->getJson('/api/sync/labels');
$response->assertSuccessful();
$labels = $response->json('data');
expect($labels)->toBeArray();
expect(count($labels))->toBe(0);
});
});
describe('BankSyncController', function () {
it('only returns global banks or user banks', function () {
$attacker = User::factory()->create();
$victim = User::factory()->create();
$victimBank = Bank::factory()->for($victim)->create();
$globalBank = Bank::factory()->create(['user_id' => null]);
actingAs($attacker);
$response = $this->getJson('/api/sync/banks');
$response->assertSuccessful();
$banks = $response->json('data');
expect($banks)->toBeArray();
$bankIds = collect($banks)->pluck('id')->toArray();
expect($bankIds)->toContain($globalBank->id);
expect($bankIds)->not->toContain($victimBank->id);
});
});
describe('AutomationRuleSyncController', function () {
it('cannot list automation rules from another user', function () {
$attacker = User::factory()->create();
$victim = User::factory()->create();
AutomationRule::factory()->for($victim)->count(3)->create();
actingAs($attacker);
$response = $this->getJson('/api/sync/automation-rules');
$response->assertSuccessful();
$rules = $response->json();
expect($rules)->toBeArray();
expect(count($rules))->toBe(0);
});
});
});

View File

@ -1,80 +0,0 @@
<?php
use App\Models\Account;
use App\Models\Bank;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\getJson;
beforeEach(function () {
$this->user = User::factory()->create();
});
it('returns all accounts for authenticated user', function () {
actingAs($this->user);
$bank = Bank::factory()->create(['user_id' => $this->user->id]);
$accounts = Account::factory()
->count(3)
->create([
'user_id' => $this->user->id,
'bank_id' => $bank->id,
]);
Account::factory()
->count(2)
->create();
$response = getJson('/api/sync/accounts');
$response->assertSuccessful();
$response->assertJsonCount(3, 'data');
});
it('returns only accounts updated after specified timestamp', function () {
actingAs($this->user);
$bank = Bank::factory()->create(['user_id' => $this->user->id]);
$oldAccount = Account::factory()->create([
'user_id' => $this->user->id,
'bank_id' => $bank->id,
'updated_at' => now()->subDays(2),
]);
$newAccount = Account::factory()->create([
'user_id' => $this->user->id,
'bank_id' => $bank->id,
'updated_at' => now(),
]);
$response = getJson('/api/sync/accounts?since='.now()->subDay()->toIso8601String());
$response->assertSuccessful();
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $newAccount->id);
});
it('includes bank relationship', function () {
actingAs($this->user);
$bank = Bank::factory()->create(['user_id' => $this->user->id]);
$account = Account::factory()->create([
'user_id' => $this->user->id,
'bank_id' => $bank->id,
]);
$response = getJson('/api/sync/accounts');
$response->assertSuccessful();
$response->assertJsonPath('data.0.bank.id', $bank->id);
});
it('requires authentication', function () {
$response = getJson('/api/sync/accounts');
$response->assertUnauthorized();
});

View File

@ -1,57 +0,0 @@
<?php
use App\Models\Bank;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\getJson;
beforeEach(function () {
$this->user = User::factory()->create();
});
it('returns shared banks and user banks but not other users banks', function () {
actingAs($this->user);
$otherUser = User::factory()->create();
$sharedBank = Bank::factory()->create(['user_id' => null]);
$userBank = Bank::factory()->create(['user_id' => $this->user->id]);
$otherUserBank = Bank::factory()->create(['user_id' => $otherUser->id]);
$response = getJson('/api/sync/banks');
$response->assertSuccessful();
$bankIds = collect($response->json('data'))->pluck('id')->toArray();
expect($bankIds)->toContain($sharedBank->id);
expect($bankIds)->toContain($userBank->id);
expect($bankIds)->not->toContain($otherUserBank->id);
});
it('returns only banks updated after specified timestamp', function () {
actingAs($this->user);
$oldBank = Bank::factory()->create([
'user_id' => null,
'updated_at' => now()->subDays(2),
]);
$newBank = Bank::factory()->create([
'user_id' => null,
'updated_at' => now(),
]);
$response = getJson('/api/sync/banks?since='.now()->subDay()->toIso8601String());
$response->assertSuccessful();
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $newBank->id);
});
it('requires authentication', function () {
$response = getJson('/api/sync/banks');
$response->assertUnauthorized();
});

View File

@ -1,54 +0,0 @@
<?php
use App\Models\Category;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\getJson;
beforeEach(function () {
$this->user = User::factory()->create();
});
it('returns all categories for authenticated user', function () {
actingAs($this->user);
$categories = Category::factory()
->count(3)
->create(['user_id' => $this->user->id]);
Category::factory()
->count(2)
->create();
$response = getJson('/api/sync/categories');
$response->assertSuccessful();
$response->assertJsonCount(3, 'data');
});
it('returns only categories updated after specified timestamp', function () {
actingAs($this->user);
$oldCategory = Category::factory()->create([
'user_id' => $this->user->id,
'updated_at' => now()->subDays(2),
]);
$newCategory = Category::factory()->create([
'user_id' => $this->user->id,
'updated_at' => now(),
]);
$response = getJson('/api/sync/categories?since='.now()->subDay()->toIso8601String());
$response->assertSuccessful();
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $newCategory->id);
});
it('requires authentication', function () {
$response = getJson('/api/sync/categories');
$response->assertUnauthorized();
});

View File

@ -1,54 +0,0 @@
<?php
use App\Models\Label;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\getJson;
beforeEach(function () {
$this->user = User::factory()->create();
});
it('returns all labels for authenticated user', function () {
actingAs($this->user);
Label::factory()
->count(3)
->create(['user_id' => $this->user->id]);
Label::factory()
->count(2)
->create();
$response = getJson('/api/sync/labels');
$response->assertSuccessful();
$response->assertJsonCount(3, 'data');
});
it('returns only labels updated after specified timestamp', function () {
actingAs($this->user);
$oldLabel = Label::factory()->create([
'user_id' => $this->user->id,
'updated_at' => now()->subDays(2),
]);
$newLabel = Label::factory()->create([
'user_id' => $this->user->id,
'updated_at' => now(),
]);
$response = getJson('/api/sync/labels?since='.now()->subDay()->toIso8601String());
$response->assertSuccessful();
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $newLabel->id);
});
it('requires authentication', function () {
$response = getJson('/api/sync/labels');
$response->assertUnauthorized();
});

View File

@ -15,6 +15,8 @@ pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature', 'Browser');
pest()->browser()->timeout(15000);
/*
|--------------------------------------------------------------------------
| Expectations

View File

@ -30,10 +30,16 @@ abstract class TestCase extends BaseTestCase
protected function setupEncryptionKey($page, ?string $key = null, bool $reload = true): void
{
$key = $key ?? $this->generateTestEncryptionKey();
try {
$page->script("localStorage.setItem('encryption_key', ".json_encode($key).')');
} catch (\Exception) {
// If script fails (page not ready), wait and retry
$page->wait(0.2);
$page->script("localStorage.setItem('encryption_key', ".json_encode($key).')');
}
if ($reload) {
$currentUrl = $page->url();
$page->navigate($currentUrl)->wait(1);
$page->navigate($currentUrl)->wait(0.5);
}
}