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_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
@ -42,7 +42,6 @@ CACHE_STORE=database
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=redis REDIS_HOST=redis
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379

View File

@ -15,12 +15,20 @@ RUN apt-get update && apt-get install -y \
netcat-openbsd \ netcat-openbsd \
&& rm -rf /var/lib/apt/lists/* && 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 \ RUN pecl install redis \
&& docker-php-ext-enable redis && docker-php-ext-enable redis
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer 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 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"] 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); 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. * 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']); ->get(['id', 'title', 'priority', 'rules_json', 'action_category_id', 'action_note', 'action_note_iv']);
return Inertia::render('settings/automation-rules', [ 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\Http\Requests\Settings\StoreBankRequest;
use App\Models\Bank; use App\Models\Bank;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class BankController extends Controller 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 public function store(StoreBankRequest $request): JsonResponse
{ {
$data = [ $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\StoreTransactionRequest;
use App\Http\Requests\UpdateTransactionRequest; use App\Http\Requests\UpdateTransactionRequest;
use App\Models\Account; use App\Models\Account;
use App\Models\AutomationRule;
use App\Models\Bank; use App\Models\Bank;
use App\Models\Category; use App\Models\Category;
use App\Models\Label; use App\Models\Label;
@ -48,11 +49,17 @@ class TransactionController extends Controller
->orderBy('name') ->orderBy('name')
->get(['id', 'name', 'color']); ->get(['id', 'name', 'color']);
$automationRules = AutomationRule::query()
->where('user_id', $user->id)
->orderBy('priority')
->get();
return Inertia::render('transactions/index', [ return Inertia::render('transactions/index', [
'categories' => $categories, 'categories' => $categories,
'accounts' => $accounts, 'accounts' => $accounts,
'banks' => $banks, 'banks' => $banks,
'labels' => $labels, 'labels' => $labels,
'automationRules' => $automationRules,
]); ]);
} }

View File

@ -69,6 +69,23 @@ class HandleInertiaRequests extends Middleware
'features' => [ 'features' => [
'cashflow' => true, '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 Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\DripEmailType; use App\Enums\DripEmailType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@ -84,6 +85,15 @@ class User extends Authenticatable
return $this->hasMany(Category::class); 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 public function automationRules(): HasMany
{ {
return $this->hasMany(AutomationRule::class); return $this->hasMany(AutomationRule::class);

View File

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

View File

@ -790,6 +790,129 @@ stop_services() {
echo -e "${GREEN}Services stopped.${NC}" 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 function
install() { install() {
print_header print_header
@ -1237,19 +1360,34 @@ main() {
upgrade) upgrade)
upgrade upgrade
;; ;;
dev)
dev_mode "${2:-on}"
;;
logs)
show_logs "${2:-php}"
;;
"") "")
ask_for_action ask_for_action
;; ;;
*) *)
echo -e "${RED}Unknown command: $1${NC}" echo -e "${RED}Unknown command: $1${NC}"
echo "" echo ""
echo "Usage: $0 [install|start|stop|upgrade]" echo "Usage: $0 [install|start|stop|upgrade|dev|logs]"
echo "" echo ""
echo "Commands:" echo "Commands:"
echo " install - Install Whisper Money" echo " install - Install Whisper Money"
echo " start - Start Docker services" echo " start - Start Docker services"
echo " stop - Stop Docker services" echo " stop - Stop Docker services"
echo " upgrade - Upgrade Whisper Money" 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 ""
echo "If no command is provided, an interactive menu will be shown." echo "If no command is provided, an interactive menu will be shown."
exit 1 exit 1

View File

@ -1,8 +1,8 @@
import { import {
destroy, destroy,
index, index,
store,
} from '@/actions/App/Http/Controllers/AccountBalanceController'; } from '@/actions/App/Http/Controllers/AccountBalanceController';
import { store } from '@/actions/App/Http/Controllers/Sync/AccountBalanceSyncController';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -33,7 +33,6 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { accountBalanceSyncService } from '@/services/account-balance-sync';
import type { Account, AccountBalance } from '@/types/account'; import type { Account, AccountBalance } from '@/types/account';
import { Pencil, Trash2 } from 'lucide-react'; import { Pencil, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
@ -135,7 +134,7 @@ export function BalancesModal({
setIsEditSubmitting(true); setIsEditSubmitting(true);
try { try {
const response = await fetch(store.url(), { const response = await fetch(store.url(account.id), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -148,7 +147,6 @@ export function BalancesModal({
Accept: 'application/json', Accept: 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
account_id: account.id,
balance_date: editDate, balance_date: editDate,
balance: editAmount, balance: editAmount,
}), }),
@ -158,8 +156,6 @@ export function BalancesModal({
throw new Error('Failed to update balance'); throw new Error('Failed to update balance');
} }
await accountBalanceSyncService.sync();
setEditingBalance(null); setEditingBalance(null);
fetchBalances(currentPage); fetchBalances(currentPage);
onBalanceChange?.(); onBalanceChange?.();
@ -198,8 +194,6 @@ export function BalancesModal({
throw new Error('Failed to delete balance'); throw new Error('Failed to delete balance');
} }
await accountBalanceSyncService.sync();
setDeletingBalance(null); setDeletingBalance(null);
fetchBalances(currentPage); fetchBalances(currentPage);
onBalanceChange?.(); 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 { Button } from '@/components/ui/button';
import { import {
Command, Command,
@ -14,7 +15,6 @@ import {
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { bankSyncService } from '@/services/bank-sync';
import { type Bank } from '@/types/account'; import { type Bank } from '@/types/account';
import { Check, ChevronsUpDown, Plus } from 'lucide-react'; import { Check, ChevronsUpDown, Plus } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
@ -58,7 +58,19 @@ export function BankCombobox({
setIsLoading(true); setIsLoading(true);
try { 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); bankCache.set(query, results);
setBanks(results); setBanks(results);
} catch (error) { } catch (error) {

View File

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

View File

@ -10,7 +10,6 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { db } from '@/lib/dexie-db';
import { type Account } from '@/types/account'; import { type Account } from '@/types/account';
import { Form, router } from '@inertiajs/react'; import { Form, router } from '@inertiajs/react';
import { useState } from 'react'; import { useState } from 'react';
@ -73,8 +72,7 @@ export function DeleteAccountDialog({
<Form <Form
{...destroy.form.delete(account.id)} {...destroy.form.delete(account.id)}
onSuccess={async () => { onSuccess={() => {
await db.accounts.delete(account.id);
handleOpenChange(false); handleOpenChange(false);
if (redirectTo) { if (redirectTo) {
router.visit(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 AlertError from '@/components/alert-error';
import { import {
Drawer, Drawer,
@ -17,8 +18,6 @@ import {
parseDate, parseDate,
parseFile, parseFile,
} from '@/lib/file-parser'; } from '@/lib/file-parser';
import { accountBalanceSyncService } from '@/services/account-balance-sync';
import { accountSyncService } from '@/services/account-sync';
import { type Account } from '@/types/account'; import { type Account } from '@/types/account';
import { import {
BalanceImportStep, BalanceImportStep,
@ -39,6 +38,7 @@ import { ImportBalanceStepUpload } from './import-balances/import-balance-step-u
interface ImportBalancesDrawerProps { interface ImportBalancesDrawerProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
accounts?: Account[];
accountId?: UUID; accountId?: UUID;
onSuccess?: () => void; onSuccess?: () => void;
} }
@ -55,6 +55,7 @@ interface ImportError {
export function ImportBalancesDrawer({ export function ImportBalancesDrawer({
open, open,
onOpenChange, onOpenChange,
accounts = [],
accountId, accountId,
onSuccess, onSuccess,
}: ImportBalancesDrawerProps) { }: ImportBalancesDrawerProps) {
@ -89,15 +90,14 @@ export function ImportBalancesDrawer({
useEffect(() => { useEffect(() => {
if (state.selectedAccountId) { if (state.selectedAccountId) {
accountSyncService const account = accounts.find(
.getById(state.selectedAccountId) (a) => a.id === state.selectedAccountId,
.then((account) => { );
if (account) { if (account) {
setSelectedAccount(account); setSelectedAccount(account);
} }
});
} }
}, [state.selectedAccountId]); }, [state.selectedAccountId, accounts]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@ -381,6 +381,13 @@ export function ImportBalancesDrawer({
const BATCH_SIZE = 50; const BATCH_SIZE = 50;
let processedCount = 0; 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) { for (let i = 0; i < state.balances.length; i += BATCH_SIZE) {
const batch = state.balances.slice(i, i + BATCH_SIZE); const batch = state.balances.slice(i, i + BATCH_SIZE);
@ -388,16 +395,33 @@ export function ImportBalancesDrawer({
batch.map(async (balance, batchIndex) => { batch.map(async (balance, batchIndex) => {
const rowNumber = i + batchIndex + 1; const rowNumber = i + batchIndex + 1;
const createdBalance = const response = await fetch(
await accountBalanceSyncService.updateOrCreate( store.url(selectedAccount.id),
selectedAccount.id, {
balance.balance_date, method: 'POST',
balance.balance, 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 { return {
success: true, success: true,
balance: createdBalance, balance: data.data,
rowNumber, rowNumber,
}; };
}), }),
@ -462,10 +486,6 @@ export function ImportBalancesDrawer({
} else { } else {
toast.error('All balances failed to import'); toast.error('All balances failed to import');
} }
accountBalanceSyncService.sync().catch((syncError) => {
console.error('Failed to sync balances with backend:', syncError);
});
}; };
const moveToStep = (step: BalanceImportStep) => { const moveToStep = (step: BalanceImportStep) => {

View File

@ -2,51 +2,23 @@ import { EncryptedText } from '@/components/encrypted-text';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { accountSyncService } from '@/services/account-sync';
import { type Account } from '@/types/account'; import { type Account } from '@/types/account';
import type { UUID } from '@/types/uuid'; import type { UUID } from '@/types/uuid';
import { Building2 } from 'lucide-react'; import { Building2 } from 'lucide-react';
import { useEffect, useState } from 'react';
interface ImportBalanceStepAccountProps { interface ImportBalanceStepAccountProps {
accounts?: Account[];
selectedAccountId: UUID | null; selectedAccountId: UUID | null;
onAccountSelect: (accountId: UUID) => void; onAccountSelect: (accountId: UUID) => void;
onNext: () => void; onNext: () => void;
} }
export function ImportBalanceStepAccount({ export function ImportBalanceStepAccount({
accounts = [],
selectedAccountId, selectedAccountId,
onAccountSelect, onAccountSelect,
onNext, onNext,
}: ImportBalanceStepAccountProps) { }: 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) { if (accounts.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center py-8 text-center"> <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 { AmountInput } from '@/components/ui/amount-input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@ -11,7 +11,6 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { accountBalanceSyncService } from '@/services/account-balance-sync';
import type { Account } from '@/types/account'; import type { Account } from '@/types/account';
import { useState } from 'react'; import { useState } from 'react';
@ -53,7 +52,7 @@ export function UpdateBalanceDialog({
setError(null); setError(null);
try { try {
const response = await fetch(store.url(), { const response = await fetch(store.url(account.id), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -66,7 +65,6 @@ export function UpdateBalanceDialog({
Accept: 'application/json', Accept: 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
account_id: account.id,
balance_date: date, balance_date: date,
balance: balance, balance: balance,
}), }),
@ -77,8 +75,6 @@ export function UpdateBalanceDialog({
throw new Error(data.message || 'Failed to update balance'); throw new Error(data.message || 'Failed to update balance');
} }
await accountBalanceSyncService.sync();
handleOpenChange(false); handleOpenChange(false);
onSuccess?.(); onSuccess?.();
} catch (err) { } catch (err) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,10 @@ import { StepHeader } from '@/components/onboarding/step-header';
import { ImportTransactionsDrawer } from '@/components/transactions/import-transactions-drawer'; import { ImportTransactionsDrawer } from '@/components/transactions/import-transactions-drawer';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CreatedAccount } from '@/hooks/use-onboarding-state'; 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 { ArrowRight, FileSpreadsheet, Upload } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
@ -16,6 +20,12 @@ export function StepImportTransactions({
}: StepImportTransactionsProps) { }: StepImportTransactionsProps) {
const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [hasImported, setHasImported] = 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) => { const handleDrawerClose = (open: boolean) => {
setIsDrawerOpen(open); setIsDrawerOpen(open);
@ -113,6 +123,10 @@ export function StepImportTransactions({
<ImportTransactionsDrawer <ImportTransactionsDrawer
open={isDrawerOpen} open={isDrawerOpen}
onOpenChange={handleDrawerClose} onOpenChange={handleDrawerClose}
accounts={accounts}
categories={categories}
banks={banks}
automationRules={automationRules}
/> />
</div> </div>
); );

View File

@ -1,30 +1,12 @@
import { StepButton } from '@/components/onboarding/step-button'; import { StepButton } from '@/components/onboarding/step-button';
import { StepHeader } from '@/components/onboarding/step-header'; import { StepHeader } from '@/components/onboarding/step-header';
import { bankSyncService } from '@/services/bank-sync';
import { Bird } from 'lucide-react'; import { Bird } from 'lucide-react';
import { useEffect, useState } from 'react';
interface StepWelcomeProps { interface StepWelcomeProps {
onContinue: () => void; onContinue: () => void;
} }
export function StepWelcome({ onContinue }: StepWelcomeProps) { 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 ( return (
<div className="flex animate-in flex-col items-center text-center duration-500 fade-in slide-in-from-bottom-4"> <div className="flex animate-in flex-col items-center text-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader <StepHeader
@ -36,13 +18,7 @@ export function StepWelcome({ onContinue }: StepWelcomeProps) {
/> />
<div className="flex w-full flex-col gap-4 sm:w-auto"> <div className="flex w-full flex-col gap-4 sm:w-auto">
<StepButton <StepButton text="Let's Get Started" onClick={onContinue} />
text="Let's Get Started"
onClick={onContinue}
disabled={isSyncing}
loading={isSyncing}
loadingText="Preparing..."
/>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This will take less than 5 minutes 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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@ -13,7 +14,6 @@ import {
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { labelSyncService } from '@/services/label-sync';
import { getLabelColorClasses, LABEL_COLORS, type Label } from '@/types/label'; import { getLabelColorClasses, LABEL_COLORS, type Label } from '@/types/label';
import { Check, ChevronsUpDown, Plus, Tag, X } from 'lucide-react'; import { Check, ChevronsUpDown, Plus, Tag, X } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
@ -80,10 +80,31 @@ export function LabelCombobox({
try { try {
const randomColor = const randomColor =
LABEL_COLORS[Math.floor(Math.random() * LABEL_COLORS.length)]; LABEL_COLORS[Math.floor(Math.random() * LABEL_COLORS.length)];
const newLabel = await labelSyncService.findOrCreate(
inputValue.trim(), const response = await fetch(store.url(), {
randomColor, 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) { if (newLabel) {
onValueChange([...value, newLabel.id]); onValueChange([...value, newLabel.id]);

View File

@ -9,27 +9,12 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { useSyncContext } from '@/contexts/sync-context'; import { useSyncContext } from '@/contexts/sync-context';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { import { CloudAlert, CloudCheck, CloudOff, RefreshCw } from 'lucide-react';
CloudAlert,
CloudCheck,
CloudOff,
List,
RefreshCw,
} from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { PendingOperationsDialog } from './pending-operations-dialog';
export function SyncStatusButton() { export function SyncStatusButton() {
const { const { syncStatus, lastSyncTime, isOnline, sync, error } =
syncStatus, useSyncContext();
lastSyncTime,
isOnline,
sync,
error,
pendingOperationsCount,
pendingOperations,
} = useSyncContext();
const [showPendingDialog, setShowPendingDialog] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const getIcon = () => { const getIcon = () => {
@ -73,68 +58,34 @@ export function SyncStatusButton() {
}; };
return ( return (
<> <DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}> <DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild> <Button
<Button variant="ghost"
variant="ghost" size="icon"
size="icon" className={`relative ${isMenuOpen ? 'bg-accent' : ''} ${syncStatus === 'error' || !isOnline ? 'bg-red-100 dark:bg-red-900' : ''}`}
className={`relative ${isMenuOpen ? 'bg-accent' : ''} ${syncStatus === 'error' || !isOnline ? 'bg-red-100 dark:bg-red-900' : ''}`} >
> {getIcon()}
{getIcon()} </Button>
{pendingOperationsCount > 0 && ( </DropdownMenuTrigger>
<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"> <DropdownMenuContent align="end" className="w-56">
{pendingOperationsCount > 99 <DropdownMenuLabel className="font-normal">
? '99+' <p className="text-xs font-medium">{getStatusText()}</p>
: pendingOperationsCount} </DropdownMenuLabel>
</span> <DropdownMenuSeparator />
)} <DropdownMenuItem
</Button> onClick={(e) => {
</DropdownMenuTrigger> e.preventDefault();
<DropdownMenuContent align="end" className="w-56"> handleSyncNow();
<DropdownMenuLabel className="font-normal"> }}
<div className="flex flex-col gap-1"> disabled={syncStatus === 'syncing' || !isOnline}
<p className="text-xs font-medium"> >
{getStatusText()} <RefreshCw
</p> className={`mr-2 h-4 w-4 ${syncStatus === 'syncing' ? 'animate-spin' : ''}`}
<p className="text-xs text-muted-foreground"> />
{pendingOperationsCount} pending{' '} Sync now
{pendingOperationsCount === 1 </DropdownMenuItem>
? 'operation' </DropdownMenuContent>
: 'operations'} </DropdownMenu>
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleSyncNow();
}}
disabled={syncStatus === 'syncing' || !isOnline}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${syncStatus === 'syncing' ? 'animate-spin' : ''}`}
/>
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 { LabelCombobox } from '@/components/shared/label-combobox';
import { CategorySelect } from '@/components/transactions/category-select'; import { CategorySelect } from '@/components/transactions/category-select';
import { AmountInput } from '@/components/ui/amount-input'; import { AmountInput } from '@/components/ui/amount-input';
@ -22,12 +26,11 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useSyncContext } from '@/contexts/sync-context';
import { decrypt, encrypt, importKey } from '@/lib/crypto'; import { decrypt, encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage'; import { getStoredKey } from '@/lib/key-storage';
import { evaluateRulesForNewTransaction } from '@/lib/rule-engine'; import { evaluateRulesForNewTransaction } from '@/lib/rule-engine';
import { appendNoteIfNotPresent } from '@/lib/utils'; 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 { transactionSyncService } from '@/services/transaction-sync';
import { import {
filterTransactionalAccounts, filterTransactionalAccounts,
@ -48,6 +51,7 @@ interface EditTransactionDialogProps {
accounts: Account[]; accounts: Account[];
banks: Bank[]; banks: Bank[];
labels: Label[]; labels: Label[];
automationRules?: AutomationRule[];
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSuccess: (transaction: DecryptedTransaction) => void; onSuccess: (transaction: DecryptedTransaction) => void;
@ -60,6 +64,7 @@ export function EditTransactionDialog({
accounts, accounts,
banks, banks,
labels, labels,
automationRules = [],
open, open,
onOpenChange, onOpenChange,
onSuccess, onSuccess,
@ -69,6 +74,7 @@ export function EditTransactionDialog({
'whisper_money_update_balance_on_transaction'; 'whisper_money_update_balance_on_transaction';
const { isKeySet } = useEncryptionKey(); const { isKeySet } = useEncryptionKey();
const { sync } = useSyncContext();
const [transactionDate, setTransactionDate] = useState(''); const [transactionDate, setTransactionDate] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [amount, setAmount] = useState<number>(0); const [amount, setAmount] = useState<number>(0);
@ -80,9 +86,6 @@ export function EditTransactionDialog({
const [decryptedAccountNames, setDecryptedAccountNames] = useState< const [decryptedAccountNames, setDecryptedAccountNames] = useState<
Map<string, string> Map<string, string>
>(new Map()); >(new Map());
const [automationRules, setAutomationRules] = useState<AutomationRule[]>(
[],
);
const [updateAccountBalance, setUpdateAccountBalance] = useState(() => { const [updateAccountBalance, setUpdateAccountBalance] = useState(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const stored = localStorage.getItem(STORAGE_KEY_UPDATE_BALANCE); const stored = localStorage.getItem(STORAGE_KEY_UPDATE_BALANCE);
@ -161,21 +164,6 @@ export function EditTransactionDialog({
decryptAccountNames(); decryptAccountNames();
}, [open, mode, accounts]); }, [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() { async function checkAndApplyAutomationRules() {
if (mode !== 'create' || automationRules.length === 0) { if (mode !== 'create' || automationRules.length === 0) {
return { return {
@ -256,26 +244,60 @@ export function EditTransactionDialog({
transactionDateStr: string, transactionDateStr: string,
transactionAmount: number, transactionAmount: number,
) { ) {
const xsrfToken = decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
try { try {
const allBalances = await accountBalanceSyncService.getAll(); // Fetch balances from backend
const accountBalances = allBalances const balancesResponse = await fetch(
.filter((b) => b.account_id === accountIdToUpdate) indexBalances.url(accountIdToUpdate),
.sort( {
(a, b) => headers: {
new Date(b.balance_date).getTime() - Accept: 'application/json',
new Date(a.balance_date).getTime(), },
); },
);
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(),
);
const latestBalance = const latestBalance =
accountBalances.length > 0 ? accountBalances[0].balance : 0; accountBalances.length > 0 ? accountBalances[0].balance : 0;
const newBalance = latestBalance + transactionAmount; const newBalance = latestBalance + transactionAmount;
await accountBalanceSyncService.updateOrCreate( // Store new balance via backend
accountIdToUpdate as unknown as number, const storeResponse = await fetch(
transactionDateStr, storeBalance.url(accountIdToUpdate),
newBalance, {
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) { } catch (error) {
console.error('Failed to update account balance:', error); console.error('Failed to update account balance:', error);
toast.error('Transaction created, but failed to update balance'); toast.error('Transaction created, but failed to update balance');
@ -408,6 +430,9 @@ export function EditTransactionDialog({
onSuccess(newTransaction); onSuccess(newTransaction);
onOpenChange(false); onOpenChange(false);
// Sync to update IndexedDB
sync();
} else { } else {
if (!transaction) { if (!transaction) {
return; return;
@ -493,6 +518,9 @@ export function EditTransactionDialog({
toast.success('Transaction updated successfully'); toast.success('Transaction updated successfully');
onSuccess(updatedTransaction); onSuccess(updatedTransaction);
onOpenChange(false); onOpenChange(false);
// Sync to update IndexedDB
sync();
} }
} catch (error) { } catch (error) {
console.error('Failed to save transaction:', error); console.error('Failed to save transaction:', error);
@ -679,7 +707,10 @@ export function EditTransactionDialog({
onValueChange={setAccountId} onValueChange={setAccountId}
disabled={isSubmitting} disabled={isSubmitting}
> >
<SelectTrigger id="account" data-testid="account-select"> <SelectTrigger
id="account"
data-testid="account-select"
>
<SelectValue placeholder="Select account" /> <SelectValue placeholder="Select account" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -748,7 +779,11 @@ export function EditTransactionDialog({
> >
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSubmitting} data-testid="submit-transaction"> <Button
type="submit"
disabled={isSubmitting}
data-testid="submit-transaction"
>
{isSubmitting {isSubmitting
? 'Saving...' ? 'Saving...'
: mode === 'create' : mode === 'create'

View File

@ -2,58 +2,33 @@ import { EncryptedText } from '@/components/encrypted-text';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { accountSyncService } from '@/services/account-sync';
import { filterTransactionalAccounts, type Account } from '@/types/account'; import { filterTransactionalAccounts, type Account } from '@/types/account';
import type { UUID } from '@/types/uuid'; import type { UUID } from '@/types/uuid';
import { Building2 } from 'lucide-react'; import { Building2 } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
interface ImportStepAccountProps { interface ImportStepAccountProps {
accounts?: Account[];
selectedAccountId: UUID | null; selectedAccountId: UUID | null;
onAccountSelect: (accountId: UUID) => void; onAccountSelect: (accountId: UUID) => void;
onNext: () => void; onNext: () => void;
} }
export function ImportStepAccount({ export function ImportStepAccount({
accounts: rawAccounts = [],
selectedAccountId, selectedAccountId,
onAccountSelect, onAccountSelect,
onNext, onNext,
}: ImportStepAccountProps) { }: ImportStepAccountProps) {
const [accounts, setAccounts] = useState<Account[]>([]); const accounts = filterTransactionalAccounts(rawAccounts);
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();
}, []);
// If there is only one account, auto-select it, and proceed to next step // If there is only one account, auto-select it, and proceed to next step
useEffect(() => { useEffect(() => {
if (!loading && accounts.length === 1) { if (accounts.length === 1) {
onAccountSelect(accounts[0].id); onAccountSelect(accounts[0].id);
onNext(); onNext();
} }
}, [loading, accounts, onAccountSelect, onNext]); }, [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>
);
}
if (accounts.length === 0) { if (accounts.length === 0) {
return ( return (

View File

@ -6,23 +6,51 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { useEncryptionKey } from '@/contexts/encryption-key-context'; 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 { Upload } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ImportTransactionsDrawer } from './import-transactions-drawer'; import { ImportTransactionsDrawer } from './import-transactions-drawer';
interface ImportData {
accounts: Account[];
categories: Category[];
banks: Bank[];
automationRules: AutomationRule[];
}
export function ImportTransactionsButton() { export function ImportTransactionsButton() {
const { isKeySet } = useEncryptionKey(); const { isKeySet } = useEncryptionKey();
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [importData, setImportData] = useState<ImportData | null>(null);
const [loading, setLoading] = useState(false);
const handleOpenDrawer = () => { const handleOpenDrawer = async () => {
if (!isKeySet) { if (!isKeySet) {
toast.error( toast.error(
'Please unlock your encryption key to import transactions', 'Please unlock your encryption key to import transactions',
); );
return; return;
} }
setDrawerOpen(true);
// 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 ( return (
@ -32,12 +60,15 @@ export function ImportTransactionsButton() {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
className={`h-9 ${!isKeySet ? 'cursor-not-allowed opacity-50' : ''}`} className={`h-9 ${!isKeySet || loading ? 'cursor-not-allowed opacity-50' : ''}`}
onClick={handleOpenDrawer} onClick={handleOpenDrawer}
disabled={loading}
aria-label="Import transactions" aria-label="Import transactions"
> >
<Upload className="h-5 w-5" /> <Upload className="h-5 w-5" />
<span className="">Import</span> <span className="">
{loading ? 'Loading...' : 'Import'}
</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@ -48,10 +79,16 @@ export function ImportTransactionsButton() {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<ImportTransactionsDrawer {importData && (
open={drawerOpen} <ImportTransactionsDrawer
onOpenChange={setDrawerOpen} 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 AlertError from '@/components/alert-error';
import { import {
Drawer, Drawer,
@ -22,13 +23,10 @@ import {
import { getStoredKey } from '@/lib/key-storage'; import { getStoredKey } from '@/lib/key-storage';
import { evaluateRulesForNewTransaction } from '@/lib/rule-engine'; import { evaluateRulesForNewTransaction } from '@/lib/rule-engine';
import { useTrackEvent } from '@/lib/track-event'; 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 { 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 { import {
DateFormat, DateFormat,
ImportStep, ImportStep,
@ -44,6 +42,10 @@ import { ImportStepPreview } from './import-step-preview';
import { ImportStepUpload } from './import-step-upload'; import { ImportStepUpload } from './import-step-upload';
interface ImportTransactionsDrawerProps { interface ImportTransactionsDrawerProps {
accounts?: Account[];
categories?: Category[];
banks?: Bank[];
automationRules?: AutomationRule[];
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
@ -67,6 +69,10 @@ type ImportFunnelStep =
| 'Finish'; | 'Finish';
export function ImportTransactionsDrawer({ export function ImportTransactionsDrawer({
accounts = [],
categories = [],
banks = [],
automationRules = [],
open, open,
onOpenChange, onOpenChange,
}: ImportTransactionsDrawerProps) { }: ImportTransactionsDrawerProps) {
@ -100,15 +106,14 @@ export function ImportTransactionsDrawer({
useEffect(() => { useEffect(() => {
if (state.selectedAccountId) { if (state.selectedAccountId) {
accountSyncService const account = accounts.find(
.getById(state.selectedAccountId) (a) => a.id === state.selectedAccountId,
.then((account) => { );
if (account) { if (account) {
setSelectedAccount(account); setSelectedAccount(account);
} }
});
} }
}, [state.selectedAccountId]); }, [state.selectedAccountId, accounts]);
const trackFunnelStep = useCallback( const trackFunnelStep = useCallback(
(step: ImportFunnelStep) => { (step: ImportFunnelStep) => {
@ -281,8 +286,8 @@ export function ImportTransactionsDrawer({
state.dateFormat, state.dateFormat,
); );
const account = await accountSyncService.getById( const account = accounts.find(
state.selectedAccountId!, (a) => a.id === state.selectedAccountId,
); );
if (!account) { if (!account) {
@ -352,11 +357,7 @@ export function ImportTransactionsDrawer({
const errors: ImportError[] = []; const errors: ImportError[] = [];
const keyString = getStoredKey(); const keyString = getStoredKey();
const key = keyString ? await importKey(keyString) : null; const key = keyString ? await importKey(keyString) : null;
const rules = key ? await automationRuleSyncService.getAll() : []; const rules = key ? automationRules : [];
const freshAccounts = await accountSyncService.getAll();
const freshBanks = await bankSyncService.getAll();
const freshCategories = await categorySyncService.getAll();
const BATCH_SIZE = 20; const BATCH_SIZE = 20;
let processedCount = 0; let processedCount = 0;
@ -387,9 +388,9 @@ export function ImportTransactionsDrawer({
account_id: selectedAccount.id, account_id: selectedAccount.id,
}, },
rules, rules,
freshCategories, categories,
freshAccounts, accounts,
freshBanks, banks,
key, key,
); );
@ -488,15 +489,29 @@ export function ImportTransactionsDrawer({
if (balancesToImport.size > 0) { if (balancesToImport.size > 0) {
try { try {
const balanceRecords = Array.from( const xsrfToken = decodeURIComponent(
balancesToImport.entries(), document.cookie
).map(([date, balance]) => ({ .split('; ')
account_id: selectedAccount.id, .find((row) => row.startsWith('XSRF-TOKEN='))
balance_date: date, ?.split('=')[1] || '',
balance, );
}));
await accountBalanceSyncService.createMany(balanceRecords); 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,
}),
});
}
} catch (err) { } catch (err) {
console.error('Failed to import balances:', err); console.error('Failed to import balances:', err);
} }
@ -571,10 +586,6 @@ export function ImportTransactionsDrawer({
syncError, syncError,
); );
}); });
accountBalanceSyncService.sync().catch((syncError) => {
console.error('Failed to sync balances with backend:', syncError);
});
}; };
const handleSelectionChange = (index: number, selected: boolean) => { const handleSelectionChange = (index: number, selected: boolean) => {
@ -645,6 +656,7 @@ export function ImportTransactionsDrawer({
case ImportStep.SelectAccount: case ImportStep.SelectAccount:
return ( return (
<ImportStepAccount <ImportStepAccount
accounts={accounts}
selectedAccountId={state.selectedAccountId} selectedAccountId={state.selectedAccountId}
onAccountSelect={handleAccountSelect} onAccountSelect={handleAccountSelect}
onNext={() => { onNext={() => {

View File

@ -1,7 +1,7 @@
import { format } from 'date-fns'; import { format } from 'date-fns';
import * as Icons from 'lucide-react'; import * as Icons from 'lucide-react';
import { Check, ChevronsUpDown, Tag, X } 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 { EncryptedText } from '@/components/encrypted-text';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -51,10 +51,34 @@ export function TransactionFilters({
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false); const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
const [labelDropdownOpen, setLabelDropdownOpen] = useState(false); const [labelDropdownOpen, setLabelDropdownOpen] = useState(false);
const [searchText, setSearchText] = useState(filters.searchText);
const isUncategorizedSelected = filters.categoryIds.includes( const isUncategorizedSelected = filters.categoryIds.includes(
UNCATEGORIZED_CATEGORY_ID, 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) { function handleCategoryToggle(categoryId: number) {
const newCategoryIds = filters.categoryIds.includes(categoryId) const newCategoryIds = filters.categoryIds.includes(categoryId)
? filters.categoryIds.filter((id) => id !== categoryId) ? filters.categoryIds.filter((id) => id !== categoryId)
@ -80,6 +104,7 @@ export function TransactionFilters({
} }
function clearFilters() { function clearFilters() {
setSearchText('');
onFiltersChange({ onFiltersChange({
dateFrom: null, dateFrom: null,
dateTo: null, dateTo: null,
@ -111,13 +136,8 @@ export function TransactionFilters({
? 'Search description or notes...' ? 'Search description or notes...'
: 'Search disabled (encryption key not set)' : 'Search disabled (encryption key not set)'
} }
value={filters.searchText} value={searchText}
onChange={(e) => onChange={(e) => setSearchText(e.target.value)}
onFiltersChange({
...filters,
searchText: e.target.value,
})
}
disabled={!isKeySet} disabled={!isKeySet}
className="max-w-sm flex-1 md:max-w-full md:min-w-[350px]" 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 { getStoredKey } from '@/lib/key-storage';
import { evaluateRules } from '@/lib/rule-engine'; import { evaluateRules } from '@/lib/rule-engine';
import { appendNoteIfNotPresent } from '@/lib/utils'; import { appendNoteIfNotPresent } from '@/lib/utils';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { transactionSyncService } from '@/services/transaction-sync'; import { transactionSyncService } from '@/services/transaction-sync';
import { type Account, type Bank } 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 { type Category } from '@/types/category';
import { type Label } from '@/types/label'; import { type Label } from '@/types/label';
import { import {
@ -216,6 +216,7 @@ export interface TransactionListProps {
accounts: Account[]; accounts: Account[];
banks: Bank[]; banks: Bank[];
labels?: Label[]; labels?: Label[];
automationRules?: AutomationRule[];
accountId?: UUID; accountId?: UUID;
pageSize?: number; pageSize?: number;
hideAccountFilter?: boolean; hideAccountFilter?: boolean;
@ -230,6 +231,7 @@ export function TransactionList({
accounts, accounts,
banks, banks,
labels: initialLabels = [], labels: initialLabels = [],
automationRules = [],
accountId, accountId,
pageSize = 25, pageSize = 25,
hideAccountFilter = false, hideAccountFilter = false,
@ -252,7 +254,7 @@ export function TransactionList({
'', '',
); );
const labels = useLiveQuery(() => db.labels.toArray(), [], initialLabels); const labels = initialLabels;
const [transactions, setTransactions] = useState<DecryptedTransaction[]>( const [transactions, setTransactions] = useState<DecryptedTransaction[]>(
[], [],
@ -664,10 +666,11 @@ export function TransactionList({
consoleDebug('✓ Encryption key found'); consoleDebug('✓ Encryption key found');
const key = await importKey(keyString); const key = await importKey(keyString);
const rules = await automationRuleSyncService.getAll(); consoleDebug(
consoleDebug(`Found ${rules.length} automation rules`); `Found ${automationRules.length} automation rules`,
);
if (rules.length === 0) { if (automationRules.length === 0) {
consoleDebug('❌ No rules to evaluate'); consoleDebug('❌ No rules to evaluate');
return; return;
} }
@ -675,7 +678,7 @@ export function TransactionList({
consoleDebug('Evaluating rules against transaction...'); consoleDebug('Evaluating rules against transaction...');
const result = await evaluateRules( const result = await evaluateRules(
transaction, transaction,
rules, automationRules,
categories, categories,
accounts, accounts,
banks, banks,
@ -765,7 +768,14 @@ export function TransactionList({
consoleDebug('=== Re-evaluation complete ==='); consoleDebug('=== Re-evaluation complete ===');
} }
}, },
[isKeySet, categories, accounts, banks, updateTransaction], [
isKeySet,
categories,
accounts,
banks,
updateTransaction,
automationRules,
],
); );
async function handleBulkReEvaluateRules() { async function handleBulkReEvaluateRules() {
@ -792,10 +802,9 @@ export function TransactionList({
consoleDebug('✓ Encryption key found'); consoleDebug('✓ Encryption key found');
const key = await importKey(keyString); const key = await importKey(keyString);
const rules = await automationRuleSyncService.getAll(); consoleDebug(`Found ${automationRules.length} automation rules`);
consoleDebug(`Found ${rules.length} automation rules`);
if (rules.length === 0) { if (automationRules.length === 0) {
consoleDebug('❌ No rules to evaluate'); consoleDebug('❌ No rules to evaluate');
return; return;
} }
@ -825,7 +834,7 @@ export function TransactionList({
consoleDebug(`\nEvaluating transaction ${transaction.id}...`); consoleDebug(`\nEvaluating transaction ${transaction.id}...`);
const result = await evaluateRules( const result = await evaluateRules(
transaction, transaction,
rules, automationRules,
categories, categories,
accounts, accounts,
banks, banks,

View File

@ -1,18 +1,8 @@
import { useOnlineStatus } from '@/hooks/use-online-status'; 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 { transactionSyncService } from '@/services/transaction-sync';
import type { User } from '@/types/index.d'; import type { User } from '@/types/index.d';
import type { Page } from '@inertiajs/core'; import type { Page } from '@inertiajs/core';
import { router } from '@inertiajs/react'; import { router } from '@inertiajs/react';
import { useLiveQuery } from 'dexie-react-hooks';
import { import {
createContext, createContext,
useCallback, useCallback,
@ -32,9 +22,6 @@ interface SyncContextType {
isAuthenticated: boolean; isAuthenticated: boolean;
sync: () => Promise<void>; sync: () => Promise<void>;
error: string | null; error: string | null;
pendingOperationsCount: number;
pendingOperations: PendingChange[];
refreshPendingOperations: () => Promise<void>;
} }
const SyncContext = createContext<SyncContextType | undefined>(undefined); const SyncContext = createContext<SyncContextType | undefined>(undefined);
@ -64,9 +51,6 @@ function formatErrorMessage(error: string): string {
return 'Sync failed. Please try again.'; 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 { interface SyncProviderProps {
children: ReactNode; children: ReactNode;
initialIsAuthenticated: boolean; initialIsAuthenticated: boolean;
@ -88,16 +72,7 @@ export function SyncProvider({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [wasOffline, setWasOffline] = useState(!isOnline); const [wasOffline, setWasOffline] = useState(!isOnline);
const syncInProgressRef = useRef(false); const syncInProgressRef = useRef(false);
const userChangeCheckedRef = useRef(false); const lastUserIdRef = useRef<string | null>(null);
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
}, []);
useEffect(() => { useEffect(() => {
const unsubscribe = router.on('navigate', (event) => { const unsubscribe = router.on('navigate', (event) => {
@ -146,37 +121,11 @@ export function SyncProvider({
setError(null); setError(null);
try { try {
const [ const result = await transactionSyncService.sync();
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 allErrors = [ if (result.errors.length > 0) {
...categoriesResult.errors,
...accountsResult.errors,
...accountBalancesResult.errors,
...banksResult.errors,
...automationRulesResult.errors,
...labelsResult.errors,
...transactionsResult.errors,
];
if (allErrors.length > 0) {
const uniqueFormattedErrors = [ const uniqueFormattedErrors = [
...new Set(allErrors.map(formatErrorMessage)), ...new Set(result.errors.map(formatErrorMessage)),
]; ];
setError(uniqueFormattedErrors.join(' ')); setError(uniqueFormattedErrors.join(' '));
setSyncStatus('error'); setSyncStatus('error');
@ -210,78 +159,18 @@ export function SyncProvider({
setWasOffline(!isOnline); setWasOffline(!isOnline);
}, [isAuthenticated, isOnline, wasOffline, sync]); }, [isAuthenticated, isOnline, wasOffline, sync]);
useEffect(() => {
if (!isOnline || !isAuthenticated) {
return;
}
const interval = setInterval(() => {
sync();
}, SYNC_INTERVAL);
return () => clearInterval(interval);
}, [isAuthenticated, isOnline, sync]);
useEffect(() => { useEffect(() => {
if (!isAuthenticated || !currentUser) { if (!isAuthenticated || !currentUser) {
return; return;
} }
const checkUserAndSync = async () => { // If user changed, clear transactions
if (userChangeCheckedRef.current) { if (lastUserIdRef.current && lastUserIdRef.current !== currentUser.id) {
return; transactionSyncService.clearAll();
} }
lastUserIdRef.current = currentUser.id;
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
}, [isAuthenticated, currentUser]); }, [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 ( return (
<SyncContext.Provider <SyncContext.Provider
value={{ value={{
@ -291,9 +180,6 @@ export function SyncProvider({
isAuthenticated, isAuthenticated,
sync, sync,
error, error,
pendingOperationsCount,
pendingOperations,
refreshPendingOperations,
}} }}
> >
{children} {children}

View File

@ -2,9 +2,9 @@ import { decrypt, encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage'; import { getStoredKey } from '@/lib/key-storage';
import { evaluateRules } from '@/lib/rule-engine'; import { evaluateRules } from '@/lib/rule-engine';
import { appendNoteIfNotPresent } from '@/lib/utils'; import { appendNoteIfNotPresent } from '@/lib/utils';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { transactionSyncService } from '@/services/transaction-sync'; import { transactionSyncService } from '@/services/transaction-sync';
import type { Account, Bank } from '@/types/account'; import type { Account, Bank } from '@/types/account';
import type { AutomationRule } from '@/types/automation-rule';
import type { Category } from '@/types/category'; import type { Category } from '@/types/category';
import type { DecryptedTransaction } from '@/types/transaction'; import type { DecryptedTransaction } from '@/types/transaction';
import { useCallback } from 'react'; import { useCallback } from 'react';
@ -26,6 +26,7 @@ export function useReEvaluateAllTransactions() {
categories: Category[], categories: Category[],
accounts: Account[], accounts: Account[],
banks: Bank[], banks: Bank[],
automationRules: AutomationRule[],
options?: ReEvaluateAllOptions, options?: ReEvaluateAllOptions,
) => { ) => {
if (!transactions.length) { if (!transactions.length) {
@ -40,9 +41,8 @@ export function useReEvaluateAllTransactions() {
} }
const key = await importKey(keyString); const key = await importKey(keyString);
const rules = await automationRuleSyncService.getAll();
if (!rules.length) { if (!automationRules.length) {
toast.error('No automation rules found'); toast.error('No automation rules found');
return; return;
} }
@ -67,7 +67,7 @@ export function useReEvaluateAllTransactions() {
const result = await evaluateRules( const result = await evaluateRules(
transaction, transaction,
rules, automationRules,
categories, categories,
accounts, accounts,
banks, 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 { Transaction } from '@/types/transaction';
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 Dexie, { type EntityTable } from 'dexie'; import Dexie, { type EntityTable } from 'dexie';
export interface SyncMetadata { export interface SyncMetadata {
@ -10,47 +6,67 @@ export interface SyncMetadata {
value: string; value: string;
} }
export interface PendingChange { type WhisperMoneyDB = Dexie & {
id?: number;
store: string;
operation: 'create' | 'update' | 'delete';
data: Record<string, unknown>;
timestamp: string;
}
const db = new Dexie('whisper_money') as Dexie & {
transactions: EntityTable<Transaction, 'id'>; 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'>; sync_metadata: EntityTable<SyncMetadata, 'key'>;
pending_changes: EntityTable<PendingChange, 'id'>;
}; };
db.version(5).stores({ let dbInstance: WhisperMoneyDB | null = null;
transactions: 'id, user_id, account_id, updated_at',
accounts: 'id, user_id, bank_id, updated_at',
categories: 'id, user_id, updated_at',
banks: 'id, user_id, updated_at',
automation_rules: 'id, user_id, priority, updated_at',
account_balances: 'id, account_id, balance_date, updated_at',
sync_metadata: 'key',
pending_changes: '++id, store, timestamp',
});
db.version(6).stores({ function initializeDatabase(): WhisperMoneyDB {
transactions: 'id, user_id, account_id, updated_at', const database = new Dexie('whisper_money') as WhisperMoneyDB;
accounts: 'id, user_id, bank_id, updated_at',
categories: 'id, user_id, updated_at',
labels: 'id, user_id, updated_at',
banks: 'id, user_id, updated_at',
automation_rules: 'id, user_id, priority, updated_at',
account_balances: 'id, account_id, balance_date, updated_at',
sync_metadata: 'key',
pending_changes: '++id, store, timestamp',
});
export { db }; 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',
banks: 'id, user_id, updated_at',
automation_rules: 'id, user_id, priority, updated_at',
account_balances: 'id, account_id, balance_date, updated_at',
sync_metadata: 'key',
pending_changes: '++id, store, timestamp',
});
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',
labels: 'id, user_id, updated_at',
banks: 'id, user_id, updated_at',
automation_rules: 'id, user_id, priority, updated_at',
account_balances: 'id, account_id, balance_date, updated_at',
sync_metadata: 'key',
pending_changes: '++id, store, timestamp',
});
// 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 type { UUID } from '@/types/uuid';
import axios, { AxiosError } from 'axios'; import axios from 'axios';
import { uuidv7 } from 'uuidv7';
import { db } from './dexie-db'; 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 { export interface SyncResult {
success: boolean; success: boolean;
inserted: number; inserted: number;
updated: number; updated: number;
deleted: number;
errors: string[]; errors: string[];
} }
export class SyncManager { interface SyncOptions {
private syncInProgress = false; endpoint: string;
private lastSyncKey: string; transformFromServer?: (
data: Record<string, unknown>,
) => Record<string, unknown>;
}
constructor(private options: SyncOptions) { const LAST_SYNC_KEY = 'last_sync_transactions';
this.lastSyncKey = `last_sync_${options.storeName}`;
export class TransactionSyncManager {
private syncInProgress = false;
private options: SyncOptions;
constructor(options: SyncOptions) {
this.options = options;
} }
async getLastSyncTime(): Promise<string | null> { 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; return metadata?.value || null;
} }
async setLastSyncTime(timestamp: string): Promise<void> { async setLastSyncTime(timestamp: string): Promise<void> {
await db.sync_metadata.put({ await db.sync_metadata.put({
key: this.lastSyncKey, key: LAST_SYNC_KEY,
value: timestamp, value: timestamp,
}); });
} }
@ -63,7 +45,6 @@ export class SyncManager {
success: false, success: false,
inserted: 0, inserted: 0,
updated: 0, updated: 0,
deleted: 0,
errors: ['Sync already in progress'], errors: ['Sync already in progress'],
}; };
} }
@ -74,18 +55,11 @@ export class SyncManager {
success: true, success: true,
inserted: 0, inserted: 0,
updated: 0, updated: 0,
deleted: 0,
errors: [], errors: [],
}; };
try { try {
await this.syncFromServer(result); 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()); await this.setLastSyncTime(new Date().toISOString());
} catch (error) { } catch (error) {
result.success = false; result.success = false;
@ -115,22 +89,18 @@ export class SyncManager {
throw new Error('Invalid server response format'); throw new Error('Invalid server response format');
} }
const table = db[this.options.storeName]; const localRecords = await db.transactions.toArray();
const localRecords = await table.toArray(); const localMap = new Map(localRecords.map((r) => [r.id, r]));
const localMap = new Map(
localRecords.map((r) => [
(r as IndexedDBRecord).id,
r as IndexedDBRecord,
]),
);
const toInsert: Record<string, unknown>[] = []; const toInsert: Transaction[] = [];
const toUpdate: Record<string, unknown>[] = []; const toUpdate: Transaction[] = [];
for (const serverRecord of serverData) { for (const serverRecord of serverData) {
const transformed = this.options.transformFromServer const transformed = (
? this.options.transformFromServer(serverRecord) this.options.transformFromServer
: serverRecord; ? this.options.transformFromServer(serverRecord)
: serverRecord
) as Transaction;
const localRecord = localMap.get(transformed.id); const localRecord = localMap.get(transformed.id);
@ -147,163 +117,37 @@ export class SyncManager {
} }
if (toInsert.length > 0) { if (toInsert.length > 0) {
await table.bulkPut(toInsert); await db.transactions.bulkPut(toInsert);
result.inserted += toInsert.length; result.inserted += toInsert.length;
} }
if (toUpdate.length > 0) { if (toUpdate.length > 0) {
await table.bulkPut(toUpdate); await db.transactions.bulkPut(toUpdate);
result.updated += toUpdate.length; result.updated += toUpdate.length;
} }
} }
private async syncToServer(result: SyncResult): Promise<number[]> { async getAll(): Promise<Transaction[]> {
const pendingChanges = await db.pending_changes return await db.transactions.toArray();
.where('store') }
.equals(this.options.storeName)
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(); .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 { isSyncing(): boolean {
return this.syncInProgress; 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 <ImportBalancesDrawer
open={importBalancesOpen} open={importBalancesOpen}
onOpenChange={setImportBalancesOpen} onOpenChange={setImportBalancesOpen}
accounts={accounts}
accountId={account.id} accountId={account.id}
onSuccess={handleBalanceUpdated} onSuccess={handleBalanceUpdated}
/> />

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { Head } from '@inertiajs/react'; import { Head, usePage } from '@inertiajs/react';
import { import {
Cell, Cell,
ColumnDef, ColumnDef,
@ -12,7 +12,6 @@ import {
useReactTable, useReactTable,
VisibilityState, VisibilityState,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { useLiveQuery } from 'dexie-react-hooks';
import { ArrowUpDown, MoreHorizontal, Tag } from 'lucide-react'; import { ArrowUpDown, MoreHorizontal, Tag } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
@ -48,7 +47,6 @@ import {
} from '@/components/ui/table'; } from '@/components/ui/table';
import AppLayout from '@/layouts/app-layout'; import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout'; import SettingsLayout from '@/layouts/settings/layout';
import { db } from '@/lib/dexie-db';
import { type BreadcrumbItem } from '@/types'; import { type BreadcrumbItem } from '@/types';
import { getLabelColorClasses, type Label } from '@/types/label'; import { getLabelColorClasses, type Label } from '@/types/label';
@ -161,7 +159,7 @@ function LabelRow({ row }: { row: Row<Label> }) {
} }
export default function Labels() { export default function Labels() {
const labels = useLiveQuery(() => db.labels.toArray(), []) || []; const { labels } = usePage<{ labels: Label[] }>().props;
const [sorting, setSorting] = useState<SortingState>([ const [sorting, setSorting] = useState<SortingState>([
{ id: 'name', desc: false }, { 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 AutomationRule } from '@/types/automation-rule';
import { type Category, getCategoryColorClasses } from '@/types/category'; import { type Category, getCategoryColorClasses } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction'; 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 { parseISO } from 'date-fns';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
import { import {
@ -97,6 +97,9 @@ export default function CategorizeTransactions({
banks, banks,
}: Props) { }: Props) {
const { isKeySet } = useEncryptionKey(); const { isKeySet } = useEncryptionKey();
const { automationRules: sharedAutomationRules } = usePage<{
automationRules: AutomationRule[];
}>().props;
const transactionIds = useLiveQuery( const transactionIds = useLiveQuery(
async () => { async () => {
@ -125,19 +128,16 @@ export default function CategorizeTransactions({
const [encryptionKey, setEncryptionKey] = useState<CryptoKey | null>(null); const [encryptionKey, setEncryptionKey] = useState<CryptoKey | null>(null);
const commandInputRef = useRef<HTMLInputElement>(null); const commandInputRef = useRef<HTMLInputElement>(null);
const automationRules = useLiveQuery( const automationRules = useMemo(
async () => { () =>
const rules = await db.automation_rules.toArray(); sharedAutomationRules.map((rule) => ({
return rules.map((rule) => ({
...rule, ...rule,
rules_json: rules_json:
typeof rule.rules_json === 'string' typeof rule.rules_json === 'string'
? JSON.parse(rule.rules_json) ? JSON.parse(rule.rules_json)
: rule.rules_json, : rule.rules_json,
})) as AutomationRule[]; })) as AutomationRule[],
}, [sharedAutomationRules],
[],
[],
); );
useEffect(() => { useEffect(() => {

View File

@ -49,6 +49,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { TableCell, TableRow } from '@/components/ui/table'; import { TableCell, TableRow } from '@/components/ui/table';
import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useSyncContext } from '@/contexts/sync-context';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { decrypt, encrypt, importKey } from '@/lib/crypto'; import { decrypt, encrypt, importKey } from '@/lib/crypto';
import { consoleDebug } from '@/lib/debug'; import { consoleDebug } from '@/lib/debug';
@ -56,10 +57,10 @@ import { db } from '@/lib/dexie-db';
import { getStoredKey } from '@/lib/key-storage'; import { getStoredKey } from '@/lib/key-storage';
import { evaluateRules } from '@/lib/rule-engine'; import { evaluateRules } from '@/lib/rule-engine';
import { appendNoteIfNotPresent, cn } from '@/lib/utils'; import { appendNoteIfNotPresent, cn } from '@/lib/utils';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { transactionSyncService } from '@/services/transaction-sync'; import { transactionSyncService } from '@/services/transaction-sync';
import { type BreadcrumbItem } from '@/types'; import { type BreadcrumbItem } from '@/types';
import { type Account, type Bank } 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 { type Category } from '@/types/category';
import { type Label } from '@/types/label'; import { type Label } from '@/types/label';
import { import {
@ -79,6 +80,7 @@ interface Props {
accounts: Account[]; accounts: Account[];
banks: Bank[]; banks: Bank[];
labels: Label[]; labels: Label[];
automationRules: AutomationRule[];
} }
const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility'; const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility';
@ -357,8 +359,15 @@ export default function Transactions({
accounts, accounts,
banks, banks,
labels: initialLabels, labels: initialLabels,
automationRules,
}: Props) { }: Props) {
const { isKeySet } = useEncryptionKey(); const { isKeySet } = useEncryptionKey();
const { sync } = useSyncContext();
// Sync transactions when page loads
useEffect(() => {
sync();
}, [sync]);
const transactionIds = useLiveQuery( const transactionIds = useLiveQuery(
async () => { async () => {
@ -387,7 +396,7 @@ export default function Transactions({
const [filters, setFilters] = useState<Filters>(() => const [filters, setFilters] = useState<Filters>(() =>
parseFiltersFromURL(), parseFiltersFromURL(),
); );
const labels = useLiveQuery(() => db.labels.toArray(), [], initialLabels); const labels = initialLabels;
const [editTransaction, setEditTransaction] = const [editTransaction, setEditTransaction] =
useState<DecryptedTransaction | null>(null); useState<DecryptedTransaction | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
@ -816,7 +825,7 @@ export default function Transactions({
consoleDebug('✓ Encryption key found'); consoleDebug('✓ Encryption key found');
const key = await importKey(keyString); const key = await importKey(keyString);
const rules = await automationRuleSyncService.getAll(); const rules = automationRules;
consoleDebug(`Found ${rules.length} automation rules`); consoleDebug(`Found ${rules.length} automation rules`);
if (rules.length === 0) { if (rules.length === 0) {
@ -917,7 +926,14 @@ export default function Transactions({
consoleDebug('=== Re-evaluation complete ==='); consoleDebug('=== Re-evaluation complete ===');
} }
}, },
[isKeySet, categories, accounts, banks, updateTransaction], [
isKeySet,
categories,
accounts,
banks,
updateTransaction,
automationRules,
],
); );
async function handleBulkReEvaluateRules() { async function handleBulkReEvaluateRules() {
@ -944,7 +960,7 @@ export default function Transactions({
consoleDebug('✓ Encryption key found'); consoleDebug('✓ Encryption key found');
const key = await importKey(keyString); const key = await importKey(keyString);
const rules = await automationRuleSyncService.getAll(); const rules = automationRules;
consoleDebug(`Found ${rules.length} automation rules`); consoleDebug(`Found ${rules.length} automation rules`);
if (rules.length === 0) { if (rules.length === 0) {
@ -1211,6 +1227,9 @@ export default function Transactions({
setDeleteTransaction(null); setDeleteTransaction(null);
setIsBulkDeleteMode(false); setIsBulkDeleteMode(false);
setRowSelection({}); setRowSelection({});
// Sync to update IndexedDB
sync();
} catch (error) { } catch (error) {
console.error('Failed to delete transaction:', error); console.error('Failed to delete transaction:', error);
} finally { } finally {
@ -1282,6 +1301,9 @@ export default function Transactions({
setRowSelection({}); setRowSelection({});
setIsSelectingAll(false); setIsSelectingAll(false);
// Sync to update IndexedDB
sync();
} catch (error) { } catch (error) {
console.error('Failed to update transactions:', error); console.error('Failed to update transactions:', error);
toast.error('Failed to update transactions'); toast.error('Failed to update transactions');
@ -1329,6 +1351,9 @@ export default function Transactions({
setDeleteTransaction(null); setDeleteTransaction(null);
setIsBulkDeleteMode(false); setIsBulkDeleteMode(false);
setRowSelection({}); setRowSelection({});
// Sync to update IndexedDB
sync();
} catch (error) { } catch (error) {
console.error('Failed to delete transactions:', error); console.error('Failed to delete transactions:', error);
} finally { } finally {
@ -1430,6 +1455,9 @@ export default function Transactions({
setRowSelection({}); setRowSelection({});
setIsSelectingAll(false); setIsSelectingAll(false);
// Sync to update IndexedDB
sync();
} catch (error) { } catch (error) {
console.error('Failed to update transactions with labels:', error); console.error('Failed to update transactions with labels:', error);
toast.error('Failed to update transactions with labels'); 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 { encrypt, importKey } from '@/lib/crypto';
import { db } from '@/lib/dexie-db';
import { getStoredKey } from '@/lib/key-storage'; 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 type { UUID } from '@/types/uuid';
import { uuidv7 } from 'uuidv7'; import axios from 'axios';
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;
}
interface TransactionUpdateData extends Partial<Transaction> { interface TransactionUpdateData extends Partial<Transaction> {
label_ids?: string[]; label_ids?: string[];
@ -37,15 +20,22 @@ interface TransactionFilters {
searchText?: string; searchText?: string;
} }
function getCsrfToken(): string {
return decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
}
class TransactionSyncService { class TransactionSyncService {
private syncManager: SyncManager; private syncManager: TransactionSyncManager;
constructor() { constructor() {
this.syncManager = new SyncManager({ this.syncManager = new TransactionSyncManager({
storeName: 'transactions',
endpoint: '/api/sync/transactions', endpoint: '/api/sync/transactions',
transformFromServer: (data) => { transformFromServer: (data) => {
// Extract label_ids from labels array if present
const label_ids = data.labels?.map((l: { id: string }) => l.id); const label_ids = data.labels?.map((l: { id: string }) => l.id);
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { labels, ...rest } = data; const { labels, ...rest } = data;
@ -66,74 +56,51 @@ class TransactionSyncService {
} }
async getAll(): Promise<Transaction[]> { async getAll(): Promise<Transaction[]> {
return await this.syncManager.getAll<Transaction>(); return await this.syncManager.getAll();
} }
async getById(id: UUID): Promise<Transaction | null> { 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[]> { async getByAccountId(accountId: UUID): Promise<Transaction[]> {
try { return await this.syncManager.getByAccountId(accountId);
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 [];
}
} }
async create( async create(
data: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>, data: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>,
): Promise<Transaction> { ): Promise<Transaction> {
return await this.syncManager.createLocal<Transaction>( const response = await axios.post('/api/sync/transactions', data);
data as Omit<Transaction, 'id' | 'created_at' | 'updated_at'> & { const serverData = response.data.data;
id?: number;
created_at?: string; const label_ids = serverData.labels?.map((l: { id: string }) => l.id);
updated_at?: string; // 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( async createMany(
transactions: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>[], transactions: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>[],
): Promise<Transaction[]> { ): Promise<Transaction[]> {
try { const created: Transaction[] = [];
const timestamp = new Date().toISOString();
const created: Transaction[] = [];
for (const data of transactions) { for (const data of transactions) {
const record = { const transaction = await this.create(data);
...data, created.push(transaction);
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);
}
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.',
);
} }
return created;
} }
async update( async update(
id: string, id: string,
data: TransactionUpdateData, data: TransactionUpdateData,
): Promise<Transaction | void> { ): Promise<Transaction> {
const existing = await this.getById(id); const existing = await this.getById(id);
if (!existing) { if (!existing) {
@ -141,181 +108,67 @@ class TransactionSyncService {
} }
const { label_ids, ...transactionData } = data; const { label_ids, ...transactionData } = data;
const timestamp = new Date().toISOString();
// If label_ids are provided, we need to sync with the server immediately const response = await fetch(`/api/sync/transactions/${id}`, {
if (label_ids !== undefined) { method: 'PATCH',
const csrfToken = decodeURIComponent( headers: {
document.cookie 'Content-Type': 'application/json',
.split('; ') Accept: 'application/json',
.find((row) => row.startsWith('XSRF-TOKEN=')) 'X-Requested-With': 'XMLHttpRequest',
?.split('=')[1] || '', 'X-XSRF-TOKEN': getCsrfToken(),
); },
credentials: 'same-origin',
body: JSON.stringify({
...existing,
...transactionData,
label_ids,
}),
});
const response = await fetch(`/api/sync/transactions/${id}`, { if (!response.ok) {
method: 'PATCH', throw new Error('Failed to update transaction');
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify({
...existing,
...transactionData,
label_ids,
}),
});
if (!response.ok) {
throw new Error('Failed to update transaction');
}
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 = {
...restServerData,
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 result = await response.json();
const updated = { const serverData = result.data;
...existing,
...transactionData,
updated_at: timestamp,
};
await db.transactions.put(updated); const serverLabelIds = serverData.labels?.map(
await db.pending_changes.add({ (l: { id: string }) => l.id,
store: 'transactions', );
operation: 'update', // eslint-disable-next-line @typescript-eslint/no-unused-vars
data: updated, const { labels: _labels, ...restServerData } = serverData;
timestamp,
}); return {
...restServerData,
transaction_date: String(serverData.transaction_date).slice(0, 10),
label_ids: serverLabelIds || [],
} as Transaction;
} }
async updateMany( async updateMany(
ids: string[], ids: string[],
data: TransactionUpdateData, data: TransactionUpdateData,
): Promise<void> { ): Promise<void> {
const timestamp = new Date().toISOString();
const { label_ids, ...transactionData } = data; const { label_ids, ...transactionData } = data;
if (label_ids !== undefined) { const response = await fetch('/transactions/bulk', {
try { method: 'PATCH',
const csrfToken = decodeURIComponent( headers: {
document.cookie 'Content-Type': 'application/json',
.split('; ') Accept: 'application/json',
.find((row) => row.startsWith('XSRF-TOKEN=')) 'X-Requested-With': 'XMLHttpRequest',
?.split('=')[1] || '', 'X-XSRF-TOKEN': getCsrfToken(),
); },
credentials: 'same-origin',
const response = await fetch('/transactions/bulk', { body: JSON.stringify({
method: 'PATCH', transaction_ids: ids,
headers: { label_ids: label_ids,
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify({
transaction_ids: ids,
label_ids: label_ids,
...transactionData,
}),
});
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, ...transactionData,
updated_at: timestamp, }),
}; });
updates.push(updated); if (!response.ok) {
pendingChanges.push({ throw new Error('Failed to bulk update transactions');
store: 'transactions',
operation: 'update',
data: updated,
timestamp,
});
}
if (updates.length > 0) {
await db.transactions.bulkPut(updates);
await db.pending_changes.bulkAdd(pendingChanges);
} }
} }
@ -350,215 +203,45 @@ class TransactionSyncService {
requestFilters.label_ids = filters.labelIds; requestFilters.label_ids = filters.labelIds;
} }
try { const response = await fetch('/transactions/bulk', {
const csrfToken = decodeURIComponent( method: 'PATCH',
document.cookie headers: {
.split('; ') 'Content-Type': 'application/json',
.find((row) => row.startsWith('XSRF-TOKEN=')) Accept: 'application/json',
?.split('=')[1] || '', 'X-Requested-With': 'XMLHttpRequest',
); 'X-XSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({
filters: requestFilters,
label_ids: label_ids,
...transactionData,
}),
});
const response = await fetch('/transactions/bulk', { if (!response.ok) {
method: 'PATCH', throw new Error('Failed to bulk update transactions by filters');
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify({
filters: requestFilters,
label_ids: label_ids,
...transactionData,
}),
});
if (!response.ok) {
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;
} }
const result = await response.json();
return result.count || 0;
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
const transaction = await this.getById(id); await axios.delete(`/api/sync/transactions/${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,
});
} }
async updateManyIndividual( async updateManyIndividual(
updates: Array<{ id: string; data: TransactionUpdateData }>, updates: Array<{ id: string; data: TransactionUpdateData }>,
): Promise<void> { ): 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) { for (const { id, data } of updates) {
const existing = await this.getById(id); await this.update(id, data);
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);
} }
} }
async deleteMany(ids: string[]): Promise<void> { async deleteMany(ids: string[]): Promise<void> {
const timestamp = new Date().toISOString();
for (const id of ids) { for (const id of ids) {
const transaction = await this.getById(id); await this.delete(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,
});
} }
} }
@ -588,10 +271,6 @@ class TransactionSyncService {
return txDate >= minDate && txDate <= maxDate; 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(); const keyString = getStoredKey();
if (!keyString) { if (!keyString) {
console.warn('No encryption key found for duplicate check'); console.warn('No encryption key found for duplicate check');
@ -617,12 +296,7 @@ class TransactionSyncService {
.trim() .trim()
.replace(/\s+/g, ' '), .replace(/\s+/g, ' '),
}; };
} catch (error) { } catch {
console.error(
'Failed to decrypt transaction:',
t.id,
error,
);
return null; return null;
} }
}), }),
@ -632,11 +306,7 @@ class TransactionSyncService {
(t) => t !== null, (t) => t !== null,
); );
console.log( return transactions.map((importingTx) => {
`Successfully decrypted ${validDecryptedTransactions.length} transactions`,
);
const results = transactions.map((importingTx) => {
const normalizedDescription = importingTx.description const normalizedDescription = importingTx.description
.toLowerCase() .toLowerCase()
.trim() .trim()
@ -651,13 +321,6 @@ class TransactionSyncService {
existing.description === normalizedDescription, existing.description === normalizedDescription,
); );
}); });
const duplicateCount = results.filter((r) => r).length;
console.log(
`Found ${duplicateCount} duplicates out of ${transactions.length} transactions`,
);
return results;
} catch (error) { } catch (error) {
console.warn( console.warn(
'Duplicate check failed, assuming no duplicates:', 'Duplicate check failed, assuming no duplicates:',
@ -686,6 +349,10 @@ class TransactionSyncService {
isSyncing(): boolean { isSyncing(): boolean {
return this.syncManager.isSyncing(); return this.syncManager.isSyncing();
} }
async clearAll(): Promise<void> {
await this.syncManager.clearAll();
}
} }
export const transactionSyncService = new TransactionSyncService(); export const transactionSyncService = new TransactionSyncService();

View File

@ -3,13 +3,8 @@
use App\Http\Controllers\AccountBalanceController; use App\Http\Controllers\AccountBalanceController;
use App\Http\Controllers\Api\CashflowAnalyticsController; use App\Http\Controllers\Api\CashflowAnalyticsController;
use App\Http\Controllers\Api\DashboardAnalyticsController; use App\Http\Controllers\Api\DashboardAnalyticsController;
use App\Http\Controllers\Api\ImportDataController;
use App\Http\Controllers\EncryptionController; 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 App\Http\Controllers\Sync\TransactionSyncController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -18,27 +13,21 @@ Route::middleware(['web', 'auth'])->group(function () {
Route::post('encryption/setup', [EncryptionController::class, 'setup']); Route::post('encryption/setup', [EncryptionController::class, 'setup']);
Route::get('encryption/message', [EncryptionController::class, 'getMessage']); Route::get('encryption/message', [EncryptionController::class, 'getMessage']);
// Sync // Import Data (for import drawers)
Route::prefix('sync')->group(function () { Route::get('import/data', [ImportDataController::class, 'index']);
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']);
// Transaction Sync (for IndexedDB client-side search)
Route::prefix('sync')->group(function () {
Route::get('transactions', [TransactionSyncController::class, 'index']); Route::get('transactions', [TransactionSyncController::class, 'index']);
Route::post('transactions', [TransactionSyncController::class, 'store']); Route::post('transactions', [TransactionSyncController::class, 'store']);
Route::patch('transactions/{transaction}', [TransactionSyncController::class, 'update']); Route::patch('transactions/{transaction}', [TransactionSyncController::class, 'update']);
Route::delete('transactions/{transaction}', [TransactionSyncController::class, 'destroy']); 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 // Account Balances
Route::put('accounts/{account}/balance/current', [AccountBalanceController::class, 'updateCurrent'])->name('api.accounts.balance.update-current'); 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::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'); Route::delete('accounts/{account}/balances/{accountBalance}', [AccountBalanceController::class, 'destroy'])->name('api.accounts.balances.destroy');
// Dashboard Analytics // 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::patch('settings/accounts/{account}', [AccountController::class, 'update'])->name('accounts.update');
Route::delete('settings/accounts/{account}', [AccountController::class, 'destroy'])->name('accounts.destroy'); 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::post('settings/banks', [BankController::class, 'store'])->name('banks.store');
Route::get('settings/categories', [CategoryController::class, 'index'])->name('categories.index'); 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); actingAs($user);
$page = visit('/settings/accounts'); $page = visit('/settings/accounts');
$this->setupEncryptionKey($page);
$page->assertSee('Bank accounts') $page->assertSee('Bank accounts')
->waitForText('Test Bank') ->waitForText('Test Bank')
@ -125,6 +126,7 @@ it('can filter accounts by name', function () {
actingAs($user); actingAs($user);
$page = visit('/settings/accounts'); $page = visit('/settings/accounts');
$this->setupEncryptionKey($page);
$page->assertSee('Bank accounts') $page->assertSee('Bank accounts')
->waitForText('Test Bank') ->waitForText('Test Bank')
@ -140,12 +142,13 @@ it('can edit an existing account via dropdown menu', function () {
actingAs($user); actingAs($user);
$page = visit('/settings/accounts'); $page = visit('/settings/accounts');
$page->wait(1);
$this->setupEncryptionKey($page); $this->setupEncryptionKey($page);
// Create account via UI to ensure it syncs to IndexedDB // Create account via UI to ensure it syncs to IndexedDB
createAccountViaUI($page, 'Old Account Name', 'Edit Bank', 'Checking', 'USD'); 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') $page->assertSee('Bank accounts')
->click('button[aria-label="Open menu"]') ->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")') ->click('button[type="submit"]:has-text("Update")')
->wait(2); ->wait(2);
$page->navigate('/settings/accounts')->wait(1); $page->navigate('/settings/accounts')->wait(3);
$page->assertSee('Updated Account Name') $page->assertSee('Updated Account Name')
->assertNoJavascriptErrors(); ->assertNoJavascriptErrors();
@ -170,12 +173,13 @@ it('can delete an account via dropdown menu', function () {
actingAs($user); actingAs($user);
$page = visit('/settings/accounts'); $page = visit('/settings/accounts');
$page->wait(1);
$this->setupEncryptionKey($page); $this->setupEncryptionKey($page);
// Create account via UI to ensure it syncs to IndexedDB // Create account via UI to ensure it syncs to IndexedDB
createAccountViaUI($page, 'Account To Delete', 'Delete Bank', 'Checking', 'USD'); 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') $page->assertSee('Bank accounts')
->assertSee('Account To Delete') ->assertSee('Account To Delete')
@ -189,7 +193,7 @@ it('can delete an account via dropdown menu', function () {
->click('button[type="submit"]:has-text("Delete")') ->click('button[type="submit"]:has-text("Delete")')
->wait(2); ->wait(2);
$page->navigate('/settings/accounts')->wait(1); $page->navigate('/settings/accounts')->wait(3);
$page->assertDontSee('Account To Delete') $page->assertDontSee('Account To Delete')
->assertNoJavascriptErrors(); ->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 = $this->actingAs($user)->get(route('automation-rules.index'));
$response->assertSuccessful(); $response->assertSuccessful();
$response->assertInertia(fn ($page) => $page $response->assertInertia(
->component('settings/automation-rules') fn ($page) => $page
->has('rules', 1) ->component('settings/automation-rules')
->has('automationRules', 1)
); );
}); });
@ -196,11 +197,12 @@ test('rules are ordered by priority', function () {
$response = $this->actingAs($user)->get(route('automation-rules.index')); $response = $this->actingAs($user)->get(route('automation-rules.index'));
$response->assertSuccessful(); $response->assertSuccessful();
$response->assertInertia(fn ($page) => $page $response->assertInertia(
->has('rules', 3) fn ($page) => $page
->where('rules.0.priority', 10) ->has('automationRules', 3)
->where('rules.1.priority', 20) ->where('automationRules.0.priority', 10)
->where('rules.2.priority', 30) ->where('automationRules.1.priority', 20)
->where('automationRules.2.priority', 30)
); );
}); });

View File

@ -2,10 +2,7 @@
use App\Models\Account; use App\Models\Account;
use App\Models\AccountBalance; use App\Models\AccountBalance;
use App\Models\AutomationRule;
use App\Models\Bank;
use App\Models\Category; use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction; use App\Models\Transaction;
use App\Models\User; use App\Models\User;
@ -424,119 +421,6 @@ describe('Cross-Resource IDOR Protection', function () {
}); });
describe('Sync Endpoints 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 () { describe('TransactionSyncController', function () {
it('cannot list transactions from another user', function () { it('cannot list transactions from another user', function () {
$attacker = User::factory()->onboarded()->create(); $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) ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature', 'Browser'); ->in('Feature', 'Browser');
pest()->browser()->timeout(15000);
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Expectations | Expectations

View File

@ -30,10 +30,16 @@ abstract class TestCase extends BaseTestCase
protected function setupEncryptionKey($page, ?string $key = null, bool $reload = true): void protected function setupEncryptionKey($page, ?string $key = null, bool $reload = true): void
{ {
$key = $key ?? $this->generateTestEncryptionKey(); $key = $key ?? $this->generateTestEncryptionKey();
$page->script("localStorage.setItem('encryption_key', ".json_encode($key).')'); 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) { if ($reload) {
$currentUrl = $page->url(); $currentUrl = $page->url();
$page->navigate($currentUrl)->wait(1); $page->navigate($currentUrl)->wait(0.5);
} }
} }