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:
parent
49ed94cbc7
commit
439ec86722
|
|
@ -1,4 +1,4 @@
|
|||
APP_NAME=Laravel
|
||||
APP_NAME="Whisper Money"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
|
|
@ -42,7 +42,6 @@ CACHE_STORE=database
|
|||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
|
|
|||
10
Dockerfile
10
Dockerfile
|
|
@ -15,12 +15,20 @@ RUN apt-get update && apt-get install -y \
|
|||
netcat-openbsd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip intl
|
||||
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip intl sockets
|
||||
RUN pecl install redis \
|
||||
&& docker-php-ext-enable redis
|
||||
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Install Bun
|
||||
RUN curl -fsSL https://bun.com/install | bash
|
||||
ENV PATH="/root/.bun/bin:${PATH}"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Playwright browsers for Pest browser tests
|
||||
# Note: This requires node_modules to be installed first, so it's done at runtime
|
||||
# or you can copy package files and run: bun install && bunx playwright install --with-deps chromium
|
||||
|
||||
CMD ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8000"]
|
||||
|
|
|
|||
|
|
@ -26,6 +26,33 @@ class AccountBalanceController extends Controller
|
|||
return response()->json($balances);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new balance for an account.
|
||||
*/
|
||||
public function store(Account $account): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $account);
|
||||
|
||||
$validated = request()->validate([
|
||||
'balance' => 'required|numeric',
|
||||
'balance_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$balance = AccountBalance::updateOrCreate(
|
||||
[
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $validated['balance_date'],
|
||||
],
|
||||
[
|
||||
'balance' => $validated['balance'],
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $balance,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create the current balance for an account.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ class AutomationRuleController extends Controller
|
|||
->get(['id', 'title', 'priority', 'rules_json', 'action_category_id', 'action_note', 'action_note_iv']);
|
||||
|
||||
return Inertia::render('settings/automation-rules', [
|
||||
'rules' => $rules,
|
||||
'automationRules' => $rules,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,29 @@ use App\Http\Controllers\Controller;
|
|||
use App\Http\Requests\Settings\StoreBankRequest;
|
||||
use App\Models\Bank;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class BankController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Bank::query()
|
||||
->where(function ($q) {
|
||||
$q->whereNull('user_id')
|
||||
->orWhere('user_id', auth()->id());
|
||||
});
|
||||
|
||||
if ($request->has('search')) {
|
||||
$search = $request->input('search');
|
||||
$query->where('name', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
$banks = $query->orderBy('name')->limit(20)->get();
|
||||
|
||||
return response()->json(['data' => $banks]);
|
||||
}
|
||||
|
||||
public function store(StoreBankRequest $request): JsonResponse
|
||||
{
|
||||
$data = [
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ use App\Http\Requests\BulkUpdateTransactionsRequest;
|
|||
use App\Http\Requests\StoreTransactionRequest;
|
||||
use App\Http\Requests\UpdateTransactionRequest;
|
||||
use App\Models\Account;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Category;
|
||||
use App\Models\Label;
|
||||
|
|
@ -48,11 +49,17 @@ class TransactionController extends Controller
|
|||
->orderBy('name')
|
||||
->get(['id', 'name', 'color']);
|
||||
|
||||
$automationRules = AutomationRule::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('priority')
|
||||
->get();
|
||||
|
||||
return Inertia::render('transactions/index', [
|
||||
'categories' => $categories,
|
||||
'accounts' => $accounts,
|
||||
'banks' => $banks,
|
||||
'labels' => $labels,
|
||||
'automationRules' => $automationRules,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,23 @@ class HandleInertiaRequests extends Middleware
|
|||
'features' => [
|
||||
'cashflow' => true,
|
||||
],
|
||||
'accounts' => fn () => $user ? $user->accounts()
|
||||
->with('bank:id,name,logo')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']) : [],
|
||||
'categories' => fn () => $user ? $user->categories()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'icon', 'color']) : [],
|
||||
'banks' => fn () => $user ? $user->banks()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'logo']) : [],
|
||||
'automationRules' => fn () => $user ? $user->automationRules()
|
||||
->with('category:id,name,icon,color')
|
||||
->orderBy('priority')
|
||||
->get() : [],
|
||||
'labels' => fn () => $user ? $user->labels()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'color']) : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Models;
|
|||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Enums\DripEmailType;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
|
@ -84,6 +85,15 @@ class User extends Authenticatable
|
|||
return $this->hasMany(Category::class);
|
||||
}
|
||||
|
||||
public function banks(): HasMany
|
||||
{
|
||||
return $this->hasMany(Bank::class)
|
||||
->where(function (Builder $query) {
|
||||
$query->whereNull('user_id')
|
||||
->orWhere('banks.user_id', $this->id);
|
||||
});
|
||||
}
|
||||
|
||||
public function automationRules(): HasMany
|
||||
{
|
||||
return $this->hasMany(AutomationRule::class);
|
||||
|
|
|
|||
1
bun.lock
1
bun.lock
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
|
|
|
|||
148
public/setup.sh
148
public/setup.sh
|
|
@ -790,6 +790,129 @@ stop_services() {
|
|||
echo -e "${GREEN}Services stopped.${NC}"
|
||||
}
|
||||
|
||||
# Development mode - enable/disable
|
||||
dev_mode() {
|
||||
local action="${1:-toggle}"
|
||||
|
||||
case "$action" in
|
||||
on | start | enable)
|
||||
echo -e "${BLUE}Enabling development mode...${NC}"
|
||||
echo ""
|
||||
|
||||
# Update .env to enable DEV_MODE
|
||||
if grep -q "^DEV_MODE=" .env; then
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' 's/^DEV_MODE=.*/DEV_MODE=true/' .env
|
||||
else
|
||||
sed -i 's/^DEV_MODE=.*/DEV_MODE=true/' .env
|
||||
fi
|
||||
else
|
||||
echo "DEV_MODE=true" >>.env
|
||||
fi
|
||||
|
||||
# Restart PHP container to pick up the change
|
||||
echo -e "${YELLOW}Restarting PHP container...${NC}"
|
||||
docker compose restart php
|
||||
|
||||
# Wait for it to be ready
|
||||
sleep 3
|
||||
wait_for_service "php"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${GREEN} ✓ Development mode enabled!${NC}"
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}PHP container is now running:${NC}"
|
||||
echo -e " • ${GREEN}php artisan serve${NC} - Development server"
|
||||
echo -e " • ${GREEN}php artisan queue:listen${NC} - Queue worker"
|
||||
echo -e " • ${GREEN}php artisan pail${NC} - Log viewer"
|
||||
echo ""
|
||||
echo -e "${YELLOW}To start Vite for hot-reload, run in a separate terminal:${NC}"
|
||||
echo -e " ${GREEN}bun run dev${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}View PHP logs:${NC}"
|
||||
echo -e " ${YELLOW}whispermoney logs${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}To disable development mode:${NC}"
|
||||
echo -e " ${YELLOW}whispermoney dev off${NC}"
|
||||
echo ""
|
||||
;;
|
||||
|
||||
off | stop | disable)
|
||||
echo -e "${BLUE}Disabling development mode...${NC}"
|
||||
echo ""
|
||||
|
||||
# Update .env to disable DEV_MODE
|
||||
if grep -q "^DEV_MODE=" .env; then
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' 's/^DEV_MODE=.*/DEV_MODE=false/' .env
|
||||
else
|
||||
sed -i 's/^DEV_MODE=.*/DEV_MODE=false/' .env
|
||||
fi
|
||||
fi
|
||||
|
||||
# Restart PHP container
|
||||
echo -e "${YELLOW}Restarting PHP container...${NC}"
|
||||
docker compose restart php
|
||||
|
||||
# Wait for it to be ready
|
||||
sleep 3
|
||||
wait_for_service "php"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${GREEN} ✓ Development mode disabled!${NC}"
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}PHP container is now running in production mode.${NC}"
|
||||
echo ""
|
||||
;;
|
||||
|
||||
status)
|
||||
if [ ! -f .env ]; then
|
||||
echo -e "${RED}.env file not found${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -q "^DEV_MODE=true" .env 2>/dev/null; then
|
||||
echo -e "${GREEN}Development mode: ${GREEN}ENABLED${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Running processes in PHP container:${NC}"
|
||||
echo -e " • php artisan serve"
|
||||
echo -e " • php artisan queue:listen"
|
||||
echo -e " • php artisan pail"
|
||||
else
|
||||
echo -e "${BLUE}Development mode: ${YELLOW}DISABLED${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}To enable:${NC}"
|
||||
echo -e " ${YELLOW}whispermoney dev on${NC}"
|
||||
fi
|
||||
echo ""
|
||||
;;
|
||||
|
||||
*)
|
||||
echo -e "${RED}Invalid dev mode action: $action${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Usage:${NC}"
|
||||
echo -e " ${GREEN}whispermoney dev on${NC} - Enable development mode"
|
||||
echo -e " ${GREEN}whispermoney dev off${NC} - Disable development mode"
|
||||
echo -e " ${GREEN}whispermoney dev status${NC} - Check development mode status"
|
||||
echo ""
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Show logs
|
||||
show_logs() {
|
||||
local service="${1:-php}"
|
||||
|
||||
echo -e "${BLUE}Showing logs for ${service} (Ctrl+C to exit)...${NC}"
|
||||
echo ""
|
||||
docker compose logs -f "$service"
|
||||
}
|
||||
|
||||
# Install function
|
||||
install() {
|
||||
print_header
|
||||
|
|
@ -1237,19 +1360,34 @@ main() {
|
|||
upgrade)
|
||||
upgrade
|
||||
;;
|
||||
dev)
|
||||
dev_mode "${2:-on}"
|
||||
;;
|
||||
logs)
|
||||
show_logs "${2:-php}"
|
||||
;;
|
||||
"")
|
||||
ask_for_action
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown command: $1${NC}"
|
||||
echo ""
|
||||
echo "Usage: $0 [install|start|stop|upgrade]"
|
||||
echo "Usage: $0 [install|start|stop|upgrade|dev|logs]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " install - Install Whisper Money"
|
||||
echo " start - Start Docker services"
|
||||
echo " stop - Stop Docker services"
|
||||
echo " upgrade - Upgrade Whisper Money"
|
||||
echo " install - Install Whisper Money"
|
||||
echo " start - Start Docker services"
|
||||
echo " stop - Stop Docker services"
|
||||
echo " upgrade - Upgrade Whisper Money"
|
||||
echo " dev [on|off] - Enable/disable development mode"
|
||||
echo " logs [service] - Show logs (default: php)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " whispermoney dev on - Enable dev mode (queue, pail)"
|
||||
echo " whispermoney dev off - Disable dev mode"
|
||||
echo " whispermoney dev status - Check dev mode status"
|
||||
echo " whispermoney logs php - Show PHP logs"
|
||||
echo " whispermoney logs mysql - Show MySQL logs"
|
||||
echo ""
|
||||
echo "If no command is provided, an interactive menu will be shown."
|
||||
exit 1
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import {
|
||||
destroy,
|
||||
index,
|
||||
store,
|
||||
} from '@/actions/App/Http/Controllers/AccountBalanceController';
|
||||
import { store } from '@/actions/App/Http/Controllers/Sync/AccountBalanceSyncController';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -33,7 +33,6 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { accountBalanceSyncService } from '@/services/account-balance-sync';
|
||||
import type { Account, AccountBalance } from '@/types/account';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
|
@ -135,7 +134,7 @@ export function BalancesModal({
|
|||
|
||||
setIsEditSubmitting(true);
|
||||
try {
|
||||
const response = await fetch(store.url(), {
|
||||
const response = await fetch(store.url(account.id), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -148,7 +147,6 @@ export function BalancesModal({
|
|||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account_id: account.id,
|
||||
balance_date: editDate,
|
||||
balance: editAmount,
|
||||
}),
|
||||
|
|
@ -158,8 +156,6 @@ export function BalancesModal({
|
|||
throw new Error('Failed to update balance');
|
||||
}
|
||||
|
||||
await accountBalanceSyncService.sync();
|
||||
|
||||
setEditingBalance(null);
|
||||
fetchBalances(currentPage);
|
||||
onBalanceChange?.();
|
||||
|
|
@ -198,8 +194,6 @@ export function BalancesModal({
|
|||
throw new Error('Failed to delete balance');
|
||||
}
|
||||
|
||||
await accountBalanceSyncService.sync();
|
||||
|
||||
setDeletingBalance(null);
|
||||
fetchBalances(currentPage);
|
||||
onBalanceChange?.();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { index as indexBanks } from '@/actions/App/Http/Controllers/Settings/BankController';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
|
|
@ -14,7 +15,6 @@ import {
|
|||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { bankSyncService } from '@/services/bank-sync';
|
||||
import { type Bank } from '@/types/account';
|
||||
import { Check, ChevronsUpDown, Plus } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
|
@ -58,7 +58,19 @@ export function BankCombobox({
|
|||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const results = await bankSyncService.search(query);
|
||||
const response = await fetch(
|
||||
indexBanks.url({ query: { search: query } }),
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to search banks');
|
||||
}
|
||||
const data = await response.json();
|
||||
const results = data.data || data;
|
||||
bankCache.set(query, results);
|
||||
setBanks(results);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -206,7 +206,11 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
|
|||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting} data-testid="submit-account">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
data-testid="submit-account"
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { db } from '@/lib/dexie-db';
|
||||
import { type Account } from '@/types/account';
|
||||
import { Form, router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
|
|
@ -73,8 +72,7 @@ export function DeleteAccountDialog({
|
|||
|
||||
<Form
|
||||
{...destroy.form.delete(account.id)}
|
||||
onSuccess={async () => {
|
||||
await db.accounts.delete(account.id);
|
||||
onSuccess={() => {
|
||||
handleOpenChange(false);
|
||||
if (redirectTo) {
|
||||
router.visit(redirectTo);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { store } from '@/actions/App/Http/Controllers/AccountBalanceController';
|
||||
import AlertError from '@/components/alert-error';
|
||||
import {
|
||||
Drawer,
|
||||
|
|
@ -17,8 +18,6 @@ import {
|
|||
parseDate,
|
||||
parseFile,
|
||||
} from '@/lib/file-parser';
|
||||
import { accountBalanceSyncService } from '@/services/account-balance-sync';
|
||||
import { accountSyncService } from '@/services/account-sync';
|
||||
import { type Account } from '@/types/account';
|
||||
import {
|
||||
BalanceImportStep,
|
||||
|
|
@ -39,6 +38,7 @@ import { ImportBalanceStepUpload } from './import-balances/import-balance-step-u
|
|||
interface ImportBalancesDrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
accounts?: Account[];
|
||||
accountId?: UUID;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
|
@ -55,6 +55,7 @@ interface ImportError {
|
|||
export function ImportBalancesDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
accounts = [],
|
||||
accountId,
|
||||
onSuccess,
|
||||
}: ImportBalancesDrawerProps) {
|
||||
|
|
@ -89,15 +90,14 @@ export function ImportBalancesDrawer({
|
|||
|
||||
useEffect(() => {
|
||||
if (state.selectedAccountId) {
|
||||
accountSyncService
|
||||
.getById(state.selectedAccountId)
|
||||
.then((account) => {
|
||||
if (account) {
|
||||
setSelectedAccount(account);
|
||||
}
|
||||
});
|
||||
const account = accounts.find(
|
||||
(a) => a.id === state.selectedAccountId,
|
||||
);
|
||||
if (account) {
|
||||
setSelectedAccount(account);
|
||||
}
|
||||
}
|
||||
}, [state.selectedAccountId]);
|
||||
}, [state.selectedAccountId, accounts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
|
|
@ -381,6 +381,13 @@ export function ImportBalancesDrawer({
|
|||
const BATCH_SIZE = 50;
|
||||
let processedCount = 0;
|
||||
|
||||
const xsrfToken = decodeURIComponent(
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('XSRF-TOKEN='))
|
||||
?.split('=')[1] || '',
|
||||
);
|
||||
|
||||
for (let i = 0; i < state.balances.length; i += BATCH_SIZE) {
|
||||
const batch = state.balances.slice(i, i + BATCH_SIZE);
|
||||
|
||||
|
|
@ -388,16 +395,33 @@ export function ImportBalancesDrawer({
|
|||
batch.map(async (balance, batchIndex) => {
|
||||
const rowNumber = i + batchIndex + 1;
|
||||
|
||||
const createdBalance =
|
||||
await accountBalanceSyncService.updateOrCreate(
|
||||
selectedAccount.id,
|
||||
balance.balance_date,
|
||||
balance.balance,
|
||||
);
|
||||
const response = await fetch(
|
||||
store.url(selectedAccount.id),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': xsrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
balance_date: balance.balance_date,
|
||||
balance: balance.balance,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(
|
||||
data.message || 'Failed to create balance',
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
balance: createdBalance,
|
||||
balance: data.data,
|
||||
rowNumber,
|
||||
};
|
||||
}),
|
||||
|
|
@ -462,10 +486,6 @@ export function ImportBalancesDrawer({
|
|||
} else {
|
||||
toast.error('All balances failed to import');
|
||||
}
|
||||
|
||||
accountBalanceSyncService.sync().catch((syncError) => {
|
||||
console.error('Failed to sync balances with backend:', syncError);
|
||||
});
|
||||
};
|
||||
|
||||
const moveToStep = (step: BalanceImportStep) => {
|
||||
|
|
|
|||
|
|
@ -2,51 +2,23 @@ import { EncryptedText } from '@/components/encrypted-text';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { accountSyncService } from '@/services/account-sync';
|
||||
import { type Account } from '@/types/account';
|
||||
import type { UUID } from '@/types/uuid';
|
||||
import { Building2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface ImportBalanceStepAccountProps {
|
||||
accounts?: Account[];
|
||||
selectedAccountId: UUID | null;
|
||||
onAccountSelect: (accountId: UUID) => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export function ImportBalanceStepAccount({
|
||||
accounts = [],
|
||||
selectedAccountId,
|
||||
onAccountSelect,
|
||||
onNext,
|
||||
}: ImportBalanceStepAccountProps) {
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const data = await accountSyncService.getAll();
|
||||
setAccounts(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load accounts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading accounts...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { store } from '@/actions/App/Http/Controllers/Sync/AccountBalanceSyncController';
|
||||
import { store } from '@/actions/App/Http/Controllers/AccountBalanceController';
|
||||
import { AmountInput } from '@/components/ui/amount-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
|
|
@ -11,7 +11,6 @@ import {
|
|||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { accountBalanceSyncService } from '@/services/account-balance-sync';
|
||||
import type { Account } from '@/types/account';
|
||||
import { useState } from 'react';
|
||||
|
||||
|
|
@ -53,7 +52,7 @@ export function UpdateBalanceDialog({
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(store.url(), {
|
||||
const response = await fetch(store.url(account.id), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -66,7 +65,6 @@ export function UpdateBalanceDialog({
|
|||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account_id: account.id,
|
||||
balance_date: date,
|
||||
balance: balance,
|
||||
}),
|
||||
|
|
@ -77,8 +75,6 @@ export function UpdateBalanceDialog({
|
|||
throw new Error(data.message || 'Failed to update balance');
|
||||
}
|
||||
|
||||
await accountBalanceSyncService.sync();
|
||||
|
||||
handleOpenChange(false);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { EncryptionKeyButton } from '@/components/encryption-key-button';
|
||||
import { SyncStatusButton } from '@/components/sync-status-button';
|
||||
import { ImportTransactionsButton } from '@/components/transactions/import-transactions-button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
|
|
@ -28,7 +27,6 @@ export function AppSidebarHeader({
|
|||
orientation="vertical"
|
||||
className="data-[orientation=vertical]:h-6"
|
||||
/>
|
||||
<SyncStatusButton />
|
||||
<EncryptionKeyButton />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { usePage } from '@inertiajs/react';
|
||||
import {
|
||||
Cell,
|
||||
ColumnDef,
|
||||
|
|
@ -11,7 +12,6 @@ import {
|
|||
useReactTable,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
|
@ -53,16 +53,24 @@ import {
|
|||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { db } from '@/lib/dexie-db';
|
||||
import { type AutomationRule, getRuleActions } from '@/types/automation-rule';
|
||||
import { getCategoryColorClasses } from '@/types/category';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
import { type Label } from '@/types/label';
|
||||
|
||||
interface AutomationRulesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
|
||||
function AutomationRuleActions({
|
||||
rule,
|
||||
categories,
|
||||
labels,
|
||||
}: {
|
||||
rule: AutomationRule;
|
||||
categories: Category[];
|
||||
labels: Label[];
|
||||
}) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
|
|
@ -70,7 +78,11 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
|
|||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0" aria-label="Actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Actions"
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -91,6 +103,8 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
|
|||
|
||||
<EditAutomationRuleDialog
|
||||
rule={rule}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
|
|
@ -103,7 +117,15 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
|
|||
);
|
||||
}
|
||||
|
||||
function AutomationRuleRow({ row }: { row: Row<AutomationRule> }) {
|
||||
function AutomationRuleRow({
|
||||
row,
|
||||
categories,
|
||||
labels,
|
||||
}: {
|
||||
row: Row<AutomationRule>;
|
||||
categories: Category[];
|
||||
labels: Label[];
|
||||
}) {
|
||||
const rule = row.original;
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
|
@ -147,6 +169,8 @@ function AutomationRuleRow({ row }: { row: Row<AutomationRule> }) {
|
|||
|
||||
<EditAutomationRuleDialog
|
||||
rule={rule}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
|
|
@ -164,8 +188,13 @@ export function AutomationRulesDialog({
|
|||
onOpenChange,
|
||||
}: AutomationRulesDialogProps) {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const rawRules =
|
||||
useLiveQuery(() => db.automation_rules.toArray(), []) || [];
|
||||
const { automationRules: rawRules } = usePage<{
|
||||
automationRules: AutomationRule[];
|
||||
}>().props;
|
||||
|
||||
// Get categories and labels from globally shared Inertia data
|
||||
const categories = usePage().props.categories as Category[];
|
||||
const labels = usePage().props.labels as Label[];
|
||||
const rules = useMemo(
|
||||
() =>
|
||||
rawRules.map((rule) => ({
|
||||
|
|
@ -278,7 +307,13 @@ export function AutomationRulesDialog({
|
|||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => <AutomationRuleActions rule={row.original} />,
|
||||
cell: ({ row }) => (
|
||||
<AutomationRuleActions
|
||||
rule={row.original}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -325,7 +360,11 @@ export function AutomationRulesDialog({
|
|||
}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<CreateAutomationRuleDialog disabled={!isKeySet} />
|
||||
<CreateAutomationRuleDialog
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
disabled={!isKeySet}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[75vh] overflow-y-auto rounded-md border">
|
||||
|
|
@ -358,6 +397,8 @@ export function AutomationRulesDialog({
|
|||
<AutomationRuleRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -19,26 +19,25 @@ import {
|
|||
isValidRuleStructure,
|
||||
type RuleStructure,
|
||||
} from '@/lib/rule-builder-utils';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import { labelSyncService } from '@/services/label-sync';
|
||||
import type { Category } from '@/types/category';
|
||||
import type { Label } from '@/types/label';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface CreateAutomationRuleDialogProps {
|
||||
categories: Category[];
|
||||
labels: Label[];
|
||||
disabled?: boolean;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function CreateAutomationRuleDialog({
|
||||
categories,
|
||||
labels,
|
||||
disabled = false,
|
||||
onSuccess,
|
||||
}: CreateAutomationRuleDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [labels, setLabels] = useState<Label[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [priority, setPriority] = useState('10');
|
||||
const [ruleStructure, setRuleStructure] = useState<RuleStructure>({
|
||||
|
|
@ -50,18 +49,6 @@ export function CreateAutomationRuleDialog({
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const [categoriesData, labelsData] = await Promise.all([
|
||||
categorySyncService.getAll(),
|
||||
labelSyncService.getAll(),
|
||||
]);
|
||||
setCategories(categoriesData);
|
||||
setLabels(labelsData);
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
|
|
@ -107,7 +94,7 @@ export function CreateAutomationRuleDialog({
|
|||
{
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
onSuccess: async () => {
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setTitle('');
|
||||
setPriority('10');
|
||||
|
|
@ -118,7 +105,6 @@ export function CreateAutomationRuleDialog({
|
|||
setCategoryId('');
|
||||
setSelectedLabelIds([]);
|
||||
setErrors({});
|
||||
await automationRuleSyncService.sync();
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (errors) => {
|
||||
|
|
@ -223,12 +209,6 @@ export function CreateAutomationRuleDialog({
|
|||
labels={labels}
|
||||
placeholder="Select labels (optional)"
|
||||
allowCreate={true}
|
||||
onLabelCreated={(newLabel) => {
|
||||
setLabels((prev) => [
|
||||
...prev,
|
||||
newLabel,
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -256,7 +236,11 @@ export function CreateAutomationRuleDialog({
|
|||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting} data-testid="submit-automation-rule">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
data-testid="submit-automation-rule"
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import type { AutomationRule } from '@/types/automation-rule';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
|
|
@ -29,14 +28,13 @@ export function DeleteAutomationRuleDialog({
|
|||
}: DeleteAutomationRuleDialogProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handleDelete = () => {
|
||||
setIsDeleting(true);
|
||||
router.delete(destroy(rule.id).url, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
onSuccess: async () => {
|
||||
onSuccess: () => {
|
||||
onOpenChange(false);
|
||||
await automationRuleSyncService.sync();
|
||||
onSuccess?.();
|
||||
},
|
||||
onFinish: () => {
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@ import {
|
|||
parseJsonLogic,
|
||||
type RuleStructure,
|
||||
} from '@/lib/rule-builder-utils';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import { labelSyncService } from '@/services/label-sync';
|
||||
import type { AutomationRule } from '@/types/automation-rule';
|
||||
import type { Category } from '@/types/category';
|
||||
import type { Label as LabelType } from '@/types/label';
|
||||
|
|
@ -30,6 +27,8 @@ import { useEffect, useState } from 'react';
|
|||
|
||||
interface EditAutomationRuleDialogProps {
|
||||
rule: AutomationRule;
|
||||
categories: Category[];
|
||||
labels: LabelType[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
|
|
@ -37,12 +36,12 @@ interface EditAutomationRuleDialogProps {
|
|||
|
||||
export function EditAutomationRuleDialog({
|
||||
rule,
|
||||
categories,
|
||||
labels,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: EditAutomationRuleDialogProps) {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [labels, setLabels] = useState<LabelType[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [priority, setPriority] = useState('0');
|
||||
const [ruleStructure, setRuleStructure] = useState<RuleStructure>({
|
||||
|
|
@ -54,18 +53,6 @@ export function EditAutomationRuleDialog({
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const [categoriesData, labelsData] = await Promise.all([
|
||||
categorySyncService.getAll(),
|
||||
labelSyncService.getAll(),
|
||||
]);
|
||||
setCategories(categoriesData);
|
||||
setLabels(labelsData);
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (rule && open) {
|
||||
setTitle(rule.title);
|
||||
|
|
@ -123,10 +110,9 @@ export function EditAutomationRuleDialog({
|
|||
{
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
onSuccess: async () => {
|
||||
onSuccess: () => {
|
||||
onOpenChange(false);
|
||||
setErrors({});
|
||||
await automationRuleSyncService.sync();
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (errors) => {
|
||||
|
|
@ -223,9 +209,6 @@ export function EditAutomationRuleDialog({
|
|||
labels={labels}
|
||||
placeholder="Select labels (optional)"
|
||||
allowCreate={true}
|
||||
onLabelCreated={(newLabel) => {
|
||||
setLabels((prev) => [...prev, newLabel]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -252,7 +235,11 @@ export function EditAutomationRuleDialog({
|
|||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting} data-testid="submit-automation-rule">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
data-testid="submit-automation-rule"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import {
|
||||
CATEGORY_COLORS,
|
||||
CATEGORY_ICONS,
|
||||
|
|
@ -53,8 +52,7 @@ export function CreateCategoryDialog({
|
|||
</DialogHeader>
|
||||
<Form
|
||||
{...store.form()}
|
||||
onSuccess={async () => {
|
||||
await categorySyncService.sync();
|
||||
onSuccess={() => {
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import { type Category } from '@/types/category';
|
||||
import { Form } from '@inertiajs/react';
|
||||
|
||||
|
|
@ -37,8 +36,7 @@ export function DeleteCategoryDialog({
|
|||
</DialogHeader>
|
||||
<Form
|
||||
{...destroy.form.delete(category.id)}
|
||||
onSuccess={async () => {
|
||||
await categorySyncService.delete(category.id);
|
||||
onSuccess={() => {
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import {
|
||||
CATEGORY_COLORS,
|
||||
CATEGORY_ICONS,
|
||||
|
|
@ -57,8 +56,7 @@ export function EditCategoryDialog({
|
|||
</DialogHeader>
|
||||
<Form
|
||||
{...update.form.patch(category.id)}
|
||||
onSuccess={async () => {
|
||||
await categorySyncService.sync();
|
||||
onSuccess={() => {
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export function EncryptionKeyButton() {
|
|||
if (!encryptedMessageData) {
|
||||
fetchEncryptedMessage();
|
||||
}
|
||||
}, []);
|
||||
}, [encryptedMessageData, fetchEncryptedMessage]);
|
||||
|
||||
function handleClick() {
|
||||
if (isKeySet) {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { labelSyncService } from '@/services/label-sync';
|
||||
import { getLabelColorClasses, LABEL_COLORS } from '@/types/label';
|
||||
import { Form } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
|
|
@ -40,8 +39,7 @@ export function CreateLabelDialog({ onSuccess }: { onSuccess?: () => void }) {
|
|||
</DialogHeader>
|
||||
<Form
|
||||
{...store.form()}
|
||||
onSuccess={async () => {
|
||||
await labelSyncService.sync();
|
||||
onSuccess={() => {
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { labelSyncService } from '@/services/label-sync';
|
||||
import { type Label } from '@/types/label';
|
||||
import { Form } from '@inertiajs/react';
|
||||
|
||||
|
|
@ -37,8 +36,7 @@ export function DeleteLabelDialog({
|
|||
</DialogHeader>
|
||||
<Form
|
||||
{...destroy.form.delete(label.id)}
|
||||
onSuccess={async () => {
|
||||
await labelSyncService.delete(label.id);
|
||||
onSuccess={() => {
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { labelSyncService } from '@/services/label-sync';
|
||||
import {
|
||||
getLabelColorClasses,
|
||||
LABEL_COLORS,
|
||||
|
|
@ -49,8 +48,7 @@ export function EditLabelDialog({
|
|||
</DialogHeader>
|
||||
<Form
|
||||
{...update.form.patch(label.id)}
|
||||
onSuccess={async () => {
|
||||
await labelSyncService.sync();
|
||||
onSuccess={() => {
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { StepHeader } from '@/components/onboarding/step-header';
|
|||
import { ImportTransactionsDrawer } from '@/components/transactions/import-transactions-drawer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CreatedAccount } from '@/hooks/use-onboarding-state';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type AutomationRule } from '@/types/automation-rule';
|
||||
import { type Category } from '@/types/category';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { ArrowRight, FileSpreadsheet, Upload } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
|
|
@ -16,6 +20,12 @@ export function StepImportTransactions({
|
|||
}: StepImportTransactionsProps) {
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [hasImported, setHasImported] = useState(false);
|
||||
const { accounts, categories, banks, automationRules } = usePage<{
|
||||
accounts: Account[];
|
||||
categories: Category[];
|
||||
banks: Bank[];
|
||||
automationRules: AutomationRule[];
|
||||
}>().props;
|
||||
|
||||
const handleDrawerClose = (open: boolean) => {
|
||||
setIsDrawerOpen(open);
|
||||
|
|
@ -113,6 +123,10 @@ export function StepImportTransactions({
|
|||
<ImportTransactionsDrawer
|
||||
open={isDrawerOpen}
|
||||
onOpenChange={handleDrawerClose}
|
||||
accounts={accounts}
|
||||
categories={categories}
|
||||
banks={banks}
|
||||
automationRules={automationRules}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,30 +1,12 @@
|
|||
import { StepButton } from '@/components/onboarding/step-button';
|
||||
import { StepHeader } from '@/components/onboarding/step-header';
|
||||
import { bankSyncService } from '@/services/bank-sync';
|
||||
import { Bird } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface StepWelcomeProps {
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function StepWelcome({ onContinue }: StepWelcomeProps) {
|
||||
const [isSyncing, setIsSyncing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const syncBanks = async () => {
|
||||
try {
|
||||
await bankSyncService.sync();
|
||||
} catch (error) {
|
||||
console.error('Failed to sync banks:', error);
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
syncBanks();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex animate-in flex-col items-center text-center duration-500 fade-in slide-in-from-bottom-4">
|
||||
<StepHeader
|
||||
|
|
@ -36,13 +18,7 @@ export function StepWelcome({ onContinue }: StepWelcomeProps) {
|
|||
/>
|
||||
|
||||
<div className="flex w-full flex-col gap-4 sm:w-auto">
|
||||
<StepButton
|
||||
text="Let's Get Started"
|
||||
onClick={onContinue}
|
||||
disabled={isSyncing}
|
||||
loading={isSyncing}
|
||||
loadingText="Preparing..."
|
||||
/>
|
||||
<StepButton text="Let's Get Started" onClick={onContinue} />
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will take less than 5 minutes
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { store } from '@/actions/App/Http/Controllers/Settings/LabelController';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
|
|
@ -13,7 +14,6 @@ import {
|
|||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { labelSyncService } from '@/services/label-sync';
|
||||
import { getLabelColorClasses, LABEL_COLORS, type Label } from '@/types/label';
|
||||
import { Check, ChevronsUpDown, Plus, Tag, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
|
@ -80,10 +80,31 @@ export function LabelCombobox({
|
|||
try {
|
||||
const randomColor =
|
||||
LABEL_COLORS[Math.floor(Math.random() * LABEL_COLORS.length)];
|
||||
const newLabel = await labelSyncService.findOrCreate(
|
||||
inputValue.trim(),
|
||||
randomColor,
|
||||
);
|
||||
|
||||
const response = await fetch(store.url(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('XSRF-TOKEN='))
|
||||
?.split('=')[1] || '',
|
||||
),
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: inputValue.trim(),
|
||||
color: randomColor,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create label');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const newLabel = data.data || data;
|
||||
|
||||
if (newLabel) {
|
||||
onValueChange([...value, newLabel.id]);
|
||||
|
|
|
|||
|
|
@ -9,27 +9,12 @@ import {
|
|||
} from '@/components/ui/dropdown-menu';
|
||||
import { useSyncContext } from '@/contexts/sync-context';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
CloudAlert,
|
||||
CloudCheck,
|
||||
CloudOff,
|
||||
List,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { CloudAlert, CloudCheck, CloudOff, RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { PendingOperationsDialog } from './pending-operations-dialog';
|
||||
|
||||
export function SyncStatusButton() {
|
||||
const {
|
||||
syncStatus,
|
||||
lastSyncTime,
|
||||
isOnline,
|
||||
sync,
|
||||
error,
|
||||
pendingOperationsCount,
|
||||
pendingOperations,
|
||||
} = useSyncContext();
|
||||
const [showPendingDialog, setShowPendingDialog] = useState(false);
|
||||
const { syncStatus, lastSyncTime, isOnline, sync, error } =
|
||||
useSyncContext();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const getIcon = () => {
|
||||
|
|
@ -73,68 +58,34 @@ export function SyncStatusButton() {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`relative ${isMenuOpen ? 'bg-accent' : ''} ${syncStatus === 'error' || !isOnline ? 'bg-red-100 dark:bg-red-900' : ''}`}
|
||||
>
|
||||
{getIcon()}
|
||||
{pendingOperationsCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
|
||||
{pendingOperationsCount > 99
|
||||
? '99+'
|
||||
: pendingOperationsCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs font-medium">
|
||||
{getStatusText()}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pendingOperationsCount} pending{' '}
|
||||
{pendingOperationsCount === 1
|
||||
? 'operation'
|
||||
: 'operations'}
|
||||
</p>
|
||||
</div>
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`relative ${isMenuOpen ? 'bg-accent' : ''} ${syncStatus === 'error' || !isOnline ? 'bg-red-100 dark:bg-red-900' : ''}`}
|
||||
>
|
||||
{getIcon()}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<p className="text-xs font-medium">{getStatusText()}</p>
|
||||
</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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import {
|
||||
index as indexBalances,
|
||||
store as storeBalance,
|
||||
} from '@/actions/App/Http/Controllers/AccountBalanceController';
|
||||
import { LabelCombobox } from '@/components/shared/label-combobox';
|
||||
import { CategorySelect } from '@/components/transactions/category-select';
|
||||
import { AmountInput } from '@/components/ui/amount-input';
|
||||
|
|
@ -22,12 +26,11 @@ import {
|
|||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { useSyncContext } from '@/contexts/sync-context';
|
||||
import { decrypt, encrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { evaluateRulesForNewTransaction } from '@/lib/rule-engine';
|
||||
import { appendNoteIfNotPresent } from '@/lib/utils';
|
||||
import { accountBalanceSyncService } from '@/services/account-balance-sync';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import {
|
||||
filterTransactionalAccounts,
|
||||
|
|
@ -48,6 +51,7 @@ interface EditTransactionDialogProps {
|
|||
accounts: Account[];
|
||||
banks: Bank[];
|
||||
labels: Label[];
|
||||
automationRules?: AutomationRule[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: (transaction: DecryptedTransaction) => void;
|
||||
|
|
@ -60,6 +64,7 @@ export function EditTransactionDialog({
|
|||
accounts,
|
||||
banks,
|
||||
labels,
|
||||
automationRules = [],
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
|
|
@ -69,6 +74,7 @@ export function EditTransactionDialog({
|
|||
'whisper_money_update_balance_on_transaction';
|
||||
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const { sync } = useSyncContext();
|
||||
const [transactionDate, setTransactionDate] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [amount, setAmount] = useState<number>(0);
|
||||
|
|
@ -80,9 +86,6 @@ export function EditTransactionDialog({
|
|||
const [decryptedAccountNames, setDecryptedAccountNames] = useState<
|
||||
Map<string, string>
|
||||
>(new Map());
|
||||
const [automationRules, setAutomationRules] = useState<AutomationRule[]>(
|
||||
[],
|
||||
);
|
||||
const [updateAccountBalance, setUpdateAccountBalance] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_UPDATE_BALANCE);
|
||||
|
|
@ -161,21 +164,6 @@ export function EditTransactionDialog({
|
|||
decryptAccountNames();
|
||||
}, [open, mode, accounts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || mode !== 'create') return;
|
||||
|
||||
async function loadAutomationRules() {
|
||||
try {
|
||||
const rules = await automationRuleSyncService.getAll();
|
||||
setAutomationRules(rules);
|
||||
} catch (error) {
|
||||
console.error('Failed to load automation rules:', error);
|
||||
}
|
||||
}
|
||||
|
||||
loadAutomationRules();
|
||||
}, [open, mode]);
|
||||
|
||||
async function checkAndApplyAutomationRules() {
|
||||
if (mode !== 'create' || automationRules.length === 0) {
|
||||
return {
|
||||
|
|
@ -256,26 +244,60 @@ export function EditTransactionDialog({
|
|||
transactionDateStr: string,
|
||||
transactionAmount: number,
|
||||
) {
|
||||
const xsrfToken = decodeURIComponent(
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('XSRF-TOKEN='))
|
||||
?.split('=')[1] || '',
|
||||
);
|
||||
|
||||
try {
|
||||
const allBalances = await accountBalanceSyncService.getAll();
|
||||
const accountBalances = allBalances
|
||||
.filter((b) => b.account_id === accountIdToUpdate)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.balance_date).getTime() -
|
||||
new Date(a.balance_date).getTime(),
|
||||
);
|
||||
// Fetch balances from backend
|
||||
const balancesResponse = await fetch(
|
||||
indexBalances.url(accountIdToUpdate),
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!balancesResponse.ok) {
|
||||
throw new Error('Failed to fetch balances');
|
||||
}
|
||||
|
||||
const balancesData = await balancesResponse.json();
|
||||
const accountBalances = (balancesData.data || []).sort(
|
||||
(a: { balance_date: string }, b: { balance_date: string }) =>
|
||||
new Date(b.balance_date).getTime() -
|
||||
new Date(a.balance_date).getTime(),
|
||||
);
|
||||
|
||||
const latestBalance =
|
||||
accountBalances.length > 0 ? accountBalances[0].balance : 0;
|
||||
|
||||
const newBalance = latestBalance + transactionAmount;
|
||||
|
||||
await accountBalanceSyncService.updateOrCreate(
|
||||
accountIdToUpdate as unknown as number,
|
||||
transactionDateStr,
|
||||
newBalance,
|
||||
// Store new balance via backend
|
||||
const storeResponse = await fetch(
|
||||
storeBalance.url(accountIdToUpdate),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': xsrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
balance_date: transactionDateStr,
|
||||
balance: newBalance,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!storeResponse.ok) {
|
||||
throw new Error('Failed to store balance');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update account balance:', error);
|
||||
toast.error('Transaction created, but failed to update balance');
|
||||
|
|
@ -408,6 +430,9 @@ export function EditTransactionDialog({
|
|||
|
||||
onSuccess(newTransaction);
|
||||
onOpenChange(false);
|
||||
|
||||
// Sync to update IndexedDB
|
||||
sync();
|
||||
} else {
|
||||
if (!transaction) {
|
||||
return;
|
||||
|
|
@ -493,6 +518,9 @@ export function EditTransactionDialog({
|
|||
toast.success('Transaction updated successfully');
|
||||
onSuccess(updatedTransaction);
|
||||
onOpenChange(false);
|
||||
|
||||
// Sync to update IndexedDB
|
||||
sync();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save transaction:', error);
|
||||
|
|
@ -679,7 +707,10 @@ export function EditTransactionDialog({
|
|||
onValueChange={setAccountId}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger id="account" data-testid="account-select">
|
||||
<SelectTrigger
|
||||
id="account"
|
||||
data-testid="account-select"
|
||||
>
|
||||
<SelectValue placeholder="Select account" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -748,7 +779,11 @@ export function EditTransactionDialog({
|
|||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting} data-testid="submit-transaction">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
data-testid="submit-transaction"
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Saving...'
|
||||
: mode === 'create'
|
||||
|
|
|
|||
|
|
@ -2,58 +2,33 @@ import { EncryptedText } from '@/components/encrypted-text';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { accountSyncService } from '@/services/account-sync';
|
||||
import { filterTransactionalAccounts, type Account } from '@/types/account';
|
||||
import type { UUID } from '@/types/uuid';
|
||||
import { Building2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface ImportStepAccountProps {
|
||||
accounts?: Account[];
|
||||
selectedAccountId: UUID | null;
|
||||
onAccountSelect: (accountId: UUID) => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export function ImportStepAccount({
|
||||
accounts: rawAccounts = [],
|
||||
selectedAccountId,
|
||||
onAccountSelect,
|
||||
onNext,
|
||||
}: ImportStepAccountProps) {
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const data = await accountSyncService.getAll();
|
||||
setAccounts(filterTransactionalAccounts(data));
|
||||
} catch (error) {
|
||||
console.error('Failed to load accounts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAccounts();
|
||||
}, []);
|
||||
const accounts = filterTransactionalAccounts(rawAccounts);
|
||||
|
||||
// If there is only one account, auto-select it, and proceed to next step
|
||||
useEffect(() => {
|
||||
if (!loading && accounts.length === 1) {
|
||||
if (accounts.length === 1) {
|
||||
onAccountSelect(accounts[0].id);
|
||||
onNext();
|
||||
}
|
||||
}, [loading, accounts, onAccountSelect, onNext]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading accounts...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [accounts, onAccountSelect, onNext]);
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -6,23 +6,51 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type AutomationRule } from '@/types/automation-rule';
|
||||
import { type Category } from '@/types/category';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ImportTransactionsDrawer } from './import-transactions-drawer';
|
||||
|
||||
interface ImportData {
|
||||
accounts: Account[];
|
||||
categories: Category[];
|
||||
banks: Bank[];
|
||||
automationRules: AutomationRule[];
|
||||
}
|
||||
|
||||
export function ImportTransactionsButton() {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [importData, setImportData] = useState<ImportData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleOpenDrawer = () => {
|
||||
const handleOpenDrawer = async () => {
|
||||
if (!isKeySet) {
|
||||
toast.error(
|
||||
'Please unlock your encryption key to import transactions',
|
||||
);
|
||||
return;
|
||||
}
|
||||
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 (
|
||||
|
|
@ -32,12 +60,15 @@ export function ImportTransactionsButton() {
|
|||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`h-9 ${!isKeySet ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
className={`h-9 ${!isKeySet || loading ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
onClick={handleOpenDrawer}
|
||||
disabled={loading}
|
||||
aria-label="Import transactions"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
<span className="">Import</span>
|
||||
<span className="">
|
||||
{loading ? 'Loading...' : 'Import'}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
|
@ -48,10 +79,16 @@ export function ImportTransactionsButton() {
|
|||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<ImportTransactionsDrawer
|
||||
open={drawerOpen}
|
||||
onOpenChange={setDrawerOpen}
|
||||
/>
|
||||
{importData && (
|
||||
<ImportTransactionsDrawer
|
||||
open={drawerOpen}
|
||||
onOpenChange={setDrawerOpen}
|
||||
accounts={importData.accounts}
|
||||
categories={importData.categories}
|
||||
banks={importData.banks}
|
||||
automationRules={importData.automationRules}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { store as storeBalance } from '@/actions/App/Http/Controllers/AccountBalanceController';
|
||||
import AlertError from '@/components/alert-error';
|
||||
import {
|
||||
Drawer,
|
||||
|
|
@ -22,13 +23,10 @@ import {
|
|||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { evaluateRulesForNewTransaction } from '@/lib/rule-engine';
|
||||
import { useTrackEvent } from '@/lib/track-event';
|
||||
import { accountBalanceSyncService } from '@/services/account-balance-sync';
|
||||
import { accountSyncService } from '@/services/account-sync';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { bankSyncService } from '@/services/bank-sync';
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { type Account } from '@/types/account';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type AutomationRule } from '@/types/automation-rule';
|
||||
import { type Category } from '@/types/category';
|
||||
import {
|
||||
DateFormat,
|
||||
ImportStep,
|
||||
|
|
@ -44,6 +42,10 @@ import { ImportStepPreview } from './import-step-preview';
|
|||
import { ImportStepUpload } from './import-step-upload';
|
||||
|
||||
interface ImportTransactionsDrawerProps {
|
||||
accounts?: Account[];
|
||||
categories?: Category[];
|
||||
banks?: Bank[];
|
||||
automationRules?: AutomationRule[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
|
@ -67,6 +69,10 @@ type ImportFunnelStep =
|
|||
| 'Finish';
|
||||
|
||||
export function ImportTransactionsDrawer({
|
||||
accounts = [],
|
||||
categories = [],
|
||||
banks = [],
|
||||
automationRules = [],
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ImportTransactionsDrawerProps) {
|
||||
|
|
@ -100,15 +106,14 @@ export function ImportTransactionsDrawer({
|
|||
|
||||
useEffect(() => {
|
||||
if (state.selectedAccountId) {
|
||||
accountSyncService
|
||||
.getById(state.selectedAccountId)
|
||||
.then((account) => {
|
||||
if (account) {
|
||||
setSelectedAccount(account);
|
||||
}
|
||||
});
|
||||
const account = accounts.find(
|
||||
(a) => a.id === state.selectedAccountId,
|
||||
);
|
||||
if (account) {
|
||||
setSelectedAccount(account);
|
||||
}
|
||||
}
|
||||
}, [state.selectedAccountId]);
|
||||
}, [state.selectedAccountId, accounts]);
|
||||
|
||||
const trackFunnelStep = useCallback(
|
||||
(step: ImportFunnelStep) => {
|
||||
|
|
@ -281,8 +286,8 @@ export function ImportTransactionsDrawer({
|
|||
state.dateFormat,
|
||||
);
|
||||
|
||||
const account = await accountSyncService.getById(
|
||||
state.selectedAccountId!,
|
||||
const account = accounts.find(
|
||||
(a) => a.id === state.selectedAccountId,
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
|
|
@ -352,11 +357,7 @@ export function ImportTransactionsDrawer({
|
|||
const errors: ImportError[] = [];
|
||||
const keyString = getStoredKey();
|
||||
const key = keyString ? await importKey(keyString) : null;
|
||||
const rules = key ? await automationRuleSyncService.getAll() : [];
|
||||
|
||||
const freshAccounts = await accountSyncService.getAll();
|
||||
const freshBanks = await bankSyncService.getAll();
|
||||
const freshCategories = await categorySyncService.getAll();
|
||||
const rules = key ? automationRules : [];
|
||||
|
||||
const BATCH_SIZE = 20;
|
||||
let processedCount = 0;
|
||||
|
|
@ -387,9 +388,9 @@ export function ImportTransactionsDrawer({
|
|||
account_id: selectedAccount.id,
|
||||
},
|
||||
rules,
|
||||
freshCategories,
|
||||
freshAccounts,
|
||||
freshBanks,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
key,
|
||||
);
|
||||
|
||||
|
|
@ -488,15 +489,29 @@ export function ImportTransactionsDrawer({
|
|||
|
||||
if (balancesToImport.size > 0) {
|
||||
try {
|
||||
const balanceRecords = Array.from(
|
||||
balancesToImport.entries(),
|
||||
).map(([date, balance]) => ({
|
||||
account_id: selectedAccount.id,
|
||||
balance_date: date,
|
||||
balance,
|
||||
}));
|
||||
const xsrfToken = decodeURIComponent(
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('XSRF-TOKEN='))
|
||||
?.split('=')[1] || '',
|
||||
);
|
||||
|
||||
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) {
|
||||
console.error('Failed to import balances:', err);
|
||||
}
|
||||
|
|
@ -571,10 +586,6 @@ export function ImportTransactionsDrawer({
|
|||
syncError,
|
||||
);
|
||||
});
|
||||
|
||||
accountBalanceSyncService.sync().catch((syncError) => {
|
||||
console.error('Failed to sync balances with backend:', syncError);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectionChange = (index: number, selected: boolean) => {
|
||||
|
|
@ -645,6 +656,7 @@ export function ImportTransactionsDrawer({
|
|||
case ImportStep.SelectAccount:
|
||||
return (
|
||||
<ImportStepAccount
|
||||
accounts={accounts}
|
||||
selectedAccountId={state.selectedAccountId}
|
||||
onAccountSelect={handleAccountSelect}
|
||||
onNext={() => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { format } from 'date-fns';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { Check, ChevronsUpDown, Tag, X } from 'lucide-react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import { EncryptedText } from '@/components/encrypted-text';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
|
@ -51,10 +51,34 @@ export function TransactionFilters({
|
|||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
|
||||
const [labelDropdownOpen, setLabelDropdownOpen] = useState(false);
|
||||
const [searchText, setSearchText] = useState(filters.searchText);
|
||||
const isUncategorizedSelected = filters.categoryIds.includes(
|
||||
UNCATEGORIZED_CATEGORY_ID,
|
||||
);
|
||||
|
||||
// Debounce search text updates
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (searchText !== filters.searchText) {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
searchText,
|
||||
});
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchText]);
|
||||
|
||||
// Sync local state when filters change externally
|
||||
useEffect(() => {
|
||||
if (filters.searchText !== searchText) {
|
||||
setSearchText(filters.searchText);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters.searchText]);
|
||||
|
||||
function handleCategoryToggle(categoryId: number) {
|
||||
const newCategoryIds = filters.categoryIds.includes(categoryId)
|
||||
? filters.categoryIds.filter((id) => id !== categoryId)
|
||||
|
|
@ -80,6 +104,7 @@ export function TransactionFilters({
|
|||
}
|
||||
|
||||
function clearFilters() {
|
||||
setSearchText('');
|
||||
onFiltersChange({
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
|
|
@ -111,13 +136,8 @@ export function TransactionFilters({
|
|||
? 'Search description or notes...'
|
||||
: 'Search disabled (encryption key not set)'
|
||||
}
|
||||
value={filters.searchText}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
searchText: e.target.value,
|
||||
})
|
||||
}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
disabled={!isKeySet}
|
||||
className="max-w-sm flex-1 md:max-w-full md:min-w-[350px]"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -58,9 +58,9 @@ import { db } from '@/lib/dexie-db';
|
|||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { evaluateRules } from '@/lib/rule-engine';
|
||||
import { appendNoteIfNotPresent } from '@/lib/utils';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type AutomationRule } from '@/types/automation-rule';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Label } from '@/types/label';
|
||||
import {
|
||||
|
|
@ -216,6 +216,7 @@ export interface TransactionListProps {
|
|||
accounts: Account[];
|
||||
banks: Bank[];
|
||||
labels?: Label[];
|
||||
automationRules?: AutomationRule[];
|
||||
accountId?: UUID;
|
||||
pageSize?: number;
|
||||
hideAccountFilter?: boolean;
|
||||
|
|
@ -230,6 +231,7 @@ export function TransactionList({
|
|||
accounts,
|
||||
banks,
|
||||
labels: initialLabels = [],
|
||||
automationRules = [],
|
||||
accountId,
|
||||
pageSize = 25,
|
||||
hideAccountFilter = false,
|
||||
|
|
@ -252,7 +254,7 @@ export function TransactionList({
|
|||
'',
|
||||
);
|
||||
|
||||
const labels = useLiveQuery(() => db.labels.toArray(), [], initialLabels);
|
||||
const labels = initialLabels;
|
||||
|
||||
const [transactions, setTransactions] = useState<DecryptedTransaction[]>(
|
||||
[],
|
||||
|
|
@ -664,10 +666,11 @@ export function TransactionList({
|
|||
consoleDebug('✓ Encryption key found');
|
||||
|
||||
const key = await importKey(keyString);
|
||||
const rules = await automationRuleSyncService.getAll();
|
||||
consoleDebug(`Found ${rules.length} automation rules`);
|
||||
consoleDebug(
|
||||
`Found ${automationRules.length} automation rules`,
|
||||
);
|
||||
|
||||
if (rules.length === 0) {
|
||||
if (automationRules.length === 0) {
|
||||
consoleDebug('❌ No rules to evaluate');
|
||||
return;
|
||||
}
|
||||
|
|
@ -675,7 +678,7 @@ export function TransactionList({
|
|||
consoleDebug('Evaluating rules against transaction...');
|
||||
const result = await evaluateRules(
|
||||
transaction,
|
||||
rules,
|
||||
automationRules,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
|
|
@ -765,7 +768,14 @@ export function TransactionList({
|
|||
consoleDebug('=== Re-evaluation complete ===');
|
||||
}
|
||||
},
|
||||
[isKeySet, categories, accounts, banks, updateTransaction],
|
||||
[
|
||||
isKeySet,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
updateTransaction,
|
||||
automationRules,
|
||||
],
|
||||
);
|
||||
|
||||
async function handleBulkReEvaluateRules() {
|
||||
|
|
@ -792,10 +802,9 @@ export function TransactionList({
|
|||
consoleDebug('✓ Encryption key found');
|
||||
|
||||
const key = await importKey(keyString);
|
||||
const rules = await automationRuleSyncService.getAll();
|
||||
consoleDebug(`Found ${rules.length} automation rules`);
|
||||
consoleDebug(`Found ${automationRules.length} automation rules`);
|
||||
|
||||
if (rules.length === 0) {
|
||||
if (automationRules.length === 0) {
|
||||
consoleDebug('❌ No rules to evaluate');
|
||||
return;
|
||||
}
|
||||
|
|
@ -825,7 +834,7 @@ export function TransactionList({
|
|||
consoleDebug(`\nEvaluating transaction ${transaction.id}...`);
|
||||
const result = await evaluateRules(
|
||||
transaction,
|
||||
rules,
|
||||
automationRules,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,8 @@
|
|||
import { useOnlineStatus } from '@/hooks/use-online-status';
|
||||
import { checkDatabaseVersion } from '@/lib/db-migration-helper';
|
||||
import { db, type PendingChange } from '@/lib/dexie-db';
|
||||
import { handleUserChange } from '@/lib/user-session-storage';
|
||||
import { accountBalanceSyncService } from '@/services/account-balance-sync';
|
||||
import { accountSyncService } from '@/services/account-sync';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { bankSyncService } from '@/services/bank-sync';
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import { labelSyncService } from '@/services/label-sync';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import type { User } from '@/types/index.d';
|
||||
import type { Page } from '@inertiajs/core';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
|
|
@ -32,9 +22,6 @@ interface SyncContextType {
|
|||
isAuthenticated: boolean;
|
||||
sync: () => Promise<void>;
|
||||
error: string | null;
|
||||
pendingOperationsCount: number;
|
||||
pendingOperations: PendingChange[];
|
||||
refreshPendingOperations: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SyncContext = createContext<SyncContextType | undefined>(undefined);
|
||||
|
|
@ -64,9 +51,6 @@ function formatErrorMessage(error: string): string {
|
|||
return 'Sync failed. Please try again.';
|
||||
}
|
||||
|
||||
const SYNC_INTERVAL = 5 * 60 * 1000;
|
||||
const AUTO_SYNC_DEBOUNCE = 500; // Auto-sync 500ms after pending changes detected
|
||||
|
||||
interface SyncProviderProps {
|
||||
children: ReactNode;
|
||||
initialIsAuthenticated: boolean;
|
||||
|
|
@ -88,16 +72,7 @@ export function SyncProvider({
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [wasOffline, setWasOffline] = useState(!isOnline);
|
||||
const syncInProgressRef = useRef(false);
|
||||
const userChangeCheckedRef = useRef(false);
|
||||
const autoSyncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const pendingOperations =
|
||||
useLiveQuery(() => db.pending_changes.toArray(), []) || [];
|
||||
const pendingOperationsCount = pendingOperations.length;
|
||||
|
||||
const refreshPendingOperations = useCallback(async () => {
|
||||
// No-op: useLiveQuery handles reactivity automatically
|
||||
}, []);
|
||||
const lastUserIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = router.on('navigate', (event) => {
|
||||
|
|
@ -146,37 +121,11 @@ export function SyncProvider({
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const [
|
||||
categoriesResult,
|
||||
accountsResult,
|
||||
accountBalancesResult,
|
||||
banksResult,
|
||||
automationRulesResult,
|
||||
labelsResult,
|
||||
transactionsResult,
|
||||
] = await Promise.all([
|
||||
categorySyncService.sync(),
|
||||
accountSyncService.sync(),
|
||||
accountBalanceSyncService.sync(),
|
||||
bankSyncService.sync(),
|
||||
automationRuleSyncService.sync(),
|
||||
labelSyncService.sync(),
|
||||
transactionSyncService.sync(),
|
||||
]);
|
||||
const result = await transactionSyncService.sync();
|
||||
|
||||
const allErrors = [
|
||||
...categoriesResult.errors,
|
||||
...accountsResult.errors,
|
||||
...accountBalancesResult.errors,
|
||||
...banksResult.errors,
|
||||
...automationRulesResult.errors,
|
||||
...labelsResult.errors,
|
||||
...transactionsResult.errors,
|
||||
];
|
||||
|
||||
if (allErrors.length > 0) {
|
||||
if (result.errors.length > 0) {
|
||||
const uniqueFormattedErrors = [
|
||||
...new Set(allErrors.map(formatErrorMessage)),
|
||||
...new Set(result.errors.map(formatErrorMessage)),
|
||||
];
|
||||
setError(uniqueFormattedErrors.join(' '));
|
||||
setSyncStatus('error');
|
||||
|
|
@ -210,78 +159,18 @@ export function SyncProvider({
|
|||
setWasOffline(!isOnline);
|
||||
}, [isAuthenticated, isOnline, wasOffline, sync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOnline || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
sync();
|
||||
}, SYNC_INTERVAL);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isAuthenticated, isOnline, sync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkUserAndSync = async () => {
|
||||
if (userChangeCheckedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
// If user changed, clear transactions
|
||||
if (lastUserIdRef.current && lastUserIdRef.current !== currentUser.id) {
|
||||
transactionSyncService.clearAll();
|
||||
}
|
||||
lastUserIdRef.current = currentUser.id;
|
||||
}, [isAuthenticated, currentUser]);
|
||||
|
||||
// Auto-sync when pending changes are detected
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !isOnline || pendingOperationsCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing timeout
|
||||
if (autoSyncTimeoutRef.current) {
|
||||
clearTimeout(autoSyncTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce sync to avoid too many calls
|
||||
autoSyncTimeoutRef.current = setTimeout(() => {
|
||||
sync();
|
||||
}, AUTO_SYNC_DEBOUNCE);
|
||||
|
||||
return () => {
|
||||
if (autoSyncTimeoutRef.current) {
|
||||
clearTimeout(autoSyncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, isOnline, pendingOperationsCount, sync]);
|
||||
|
||||
return (
|
||||
<SyncContext.Provider
|
||||
value={{
|
||||
|
|
@ -291,9 +180,6 @@ export function SyncProvider({
|
|||
isAuthenticated,
|
||||
sync,
|
||||
error,
|
||||
pendingOperationsCount,
|
||||
pendingOperations,
|
||||
refreshPendingOperations,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { decrypt, encrypt, importKey } from '@/lib/crypto';
|
|||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { evaluateRules } from '@/lib/rule-engine';
|
||||
import { appendNoteIfNotPresent } from '@/lib/utils';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import type { Account, Bank } from '@/types/account';
|
||||
import type { AutomationRule } from '@/types/automation-rule';
|
||||
import type { Category } from '@/types/category';
|
||||
import type { DecryptedTransaction } from '@/types/transaction';
|
||||
import { useCallback } from 'react';
|
||||
|
|
@ -26,6 +26,7 @@ export function useReEvaluateAllTransactions() {
|
|||
categories: Category[],
|
||||
accounts: Account[],
|
||||
banks: Bank[],
|
||||
automationRules: AutomationRule[],
|
||||
options?: ReEvaluateAllOptions,
|
||||
) => {
|
||||
if (!transactions.length) {
|
||||
|
|
@ -40,9 +41,8 @@ export function useReEvaluateAllTransactions() {
|
|||
}
|
||||
|
||||
const key = await importKey(keyString);
|
||||
const rules = await automationRuleSyncService.getAll();
|
||||
|
||||
if (!rules.length) {
|
||||
if (!automationRules.length) {
|
||||
toast.error('No automation rules found');
|
||||
return;
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ export function useReEvaluateAllTransactions() {
|
|||
|
||||
const result = await evaluateRules(
|
||||
transaction,
|
||||
rules,
|
||||
automationRules,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,4 @@
|
|||
import type { Transaction } from '@/services/transaction-sync';
|
||||
import type { Account, AccountBalance, Bank } from '@/types/account';
|
||||
import type { AutomationRule } from '@/types/automation-rule';
|
||||
import type { Category } from '@/types/category';
|
||||
import type { Label } from '@/types/label';
|
||||
import type { Transaction } from '@/types/transaction';
|
||||
import Dexie, { type EntityTable } from 'dexie';
|
||||
|
||||
export interface SyncMetadata {
|
||||
|
|
@ -10,47 +6,67 @@ export interface SyncMetadata {
|
|||
value: string;
|
||||
}
|
||||
|
||||
export interface PendingChange {
|
||||
id?: number;
|
||||
store: string;
|
||||
operation: 'create' | 'update' | 'delete';
|
||||
data: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const db = new Dexie('whisper_money') as Dexie & {
|
||||
type WhisperMoneyDB = Dexie & {
|
||||
transactions: EntityTable<Transaction, 'id'>;
|
||||
accounts: EntityTable<Account, 'id'>;
|
||||
categories: EntityTable<Category, 'id'>;
|
||||
labels: EntityTable<Label, 'id'>;
|
||||
banks: EntityTable<Bank, 'id'>;
|
||||
automation_rules: EntityTable<AutomationRule, 'id'>;
|
||||
account_balances: EntityTable<AccountBalance, 'id'>;
|
||||
sync_metadata: EntityTable<SyncMetadata, 'key'>;
|
||||
pending_changes: EntityTable<PendingChange, 'id'>;
|
||||
};
|
||||
|
||||
db.version(5).stores({
|
||||
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',
|
||||
});
|
||||
let dbInstance: WhisperMoneyDB | null = null;
|
||||
|
||||
db.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',
|
||||
});
|
||||
function initializeDatabase(): WhisperMoneyDB {
|
||||
const database = new Dexie('whisper_money') as WhisperMoneyDB;
|
||||
|
||||
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];
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,58 +1,40 @@
|
|||
import type { Transaction } from '@/types/transaction';
|
||||
import type { UUID } from '@/types/uuid';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { uuidv7 } from 'uuidv7';
|
||||
import axios from 'axios';
|
||||
import { db } from './dexie-db';
|
||||
|
||||
export type StoreName =
|
||||
| 'categories'
|
||||
| 'accounts'
|
||||
| 'banks'
|
||||
| 'automation_rules'
|
||||
| 'transactions';
|
||||
|
||||
export interface IndexedDBRecord {
|
||||
id: UUID;
|
||||
user_id?: UUID | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SyncOptions {
|
||||
storeName: StoreName;
|
||||
endpoint: string;
|
||||
transformFromServer?: (
|
||||
data: Record<string, unknown>,
|
||||
) => Record<string, unknown>;
|
||||
transformToServer?: (
|
||||
data: Record<string, unknown>,
|
||||
) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
deleted: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export class SyncManager {
|
||||
private syncInProgress = false;
|
||||
private lastSyncKey: string;
|
||||
interface SyncOptions {
|
||||
endpoint: string;
|
||||
transformFromServer?: (
|
||||
data: Record<string, unknown>,
|
||||
) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
constructor(private options: SyncOptions) {
|
||||
this.lastSyncKey = `last_sync_${options.storeName}`;
|
||||
const LAST_SYNC_KEY = 'last_sync_transactions';
|
||||
|
||||
export class TransactionSyncManager {
|
||||
private syncInProgress = false;
|
||||
private options: SyncOptions;
|
||||
|
||||
constructor(options: SyncOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async getLastSyncTime(): Promise<string | null> {
|
||||
const metadata = await db.sync_metadata.get(this.lastSyncKey);
|
||||
const metadata = await db.sync_metadata.get(LAST_SYNC_KEY);
|
||||
return metadata?.value || null;
|
||||
}
|
||||
|
||||
async setLastSyncTime(timestamp: string): Promise<void> {
|
||||
await db.sync_metadata.put({
|
||||
key: this.lastSyncKey,
|
||||
key: LAST_SYNC_KEY,
|
||||
value: timestamp,
|
||||
});
|
||||
}
|
||||
|
|
@ -63,7 +45,6 @@ export class SyncManager {
|
|||
success: false,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
errors: ['Sync already in progress'],
|
||||
};
|
||||
}
|
||||
|
|
@ -74,18 +55,11 @@ export class SyncManager {
|
|||
success: true,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
await this.syncFromServer(result);
|
||||
const successfulChangeIds = await this.syncToServer(result);
|
||||
|
||||
if (successfulChangeIds.length > 0) {
|
||||
await db.pending_changes.bulkDelete(successfulChangeIds);
|
||||
}
|
||||
|
||||
await this.setLastSyncTime(new Date().toISOString());
|
||||
} catch (error) {
|
||||
result.success = false;
|
||||
|
|
@ -115,22 +89,18 @@ export class SyncManager {
|
|||
throw new Error('Invalid server response format');
|
||||
}
|
||||
|
||||
const table = db[this.options.storeName];
|
||||
const localRecords = await table.toArray();
|
||||
const localMap = new Map(
|
||||
localRecords.map((r) => [
|
||||
(r as IndexedDBRecord).id,
|
||||
r as IndexedDBRecord,
|
||||
]),
|
||||
);
|
||||
const localRecords = await db.transactions.toArray();
|
||||
const localMap = new Map(localRecords.map((r) => [r.id, r]));
|
||||
|
||||
const toInsert: Record<string, unknown>[] = [];
|
||||
const toUpdate: Record<string, unknown>[] = [];
|
||||
const toInsert: Transaction[] = [];
|
||||
const toUpdate: Transaction[] = [];
|
||||
|
||||
for (const serverRecord of serverData) {
|
||||
const transformed = this.options.transformFromServer
|
||||
? this.options.transformFromServer(serverRecord)
|
||||
: serverRecord;
|
||||
const transformed = (
|
||||
this.options.transformFromServer
|
||||
? this.options.transformFromServer(serverRecord)
|
||||
: serverRecord
|
||||
) as Transaction;
|
||||
|
||||
const localRecord = localMap.get(transformed.id);
|
||||
|
||||
|
|
@ -147,163 +117,37 @@ export class SyncManager {
|
|||
}
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await table.bulkPut(toInsert);
|
||||
await db.transactions.bulkPut(toInsert);
|
||||
result.inserted += toInsert.length;
|
||||
}
|
||||
|
||||
if (toUpdate.length > 0) {
|
||||
await table.bulkPut(toUpdate);
|
||||
await db.transactions.bulkPut(toUpdate);
|
||||
result.updated += toUpdate.length;
|
||||
}
|
||||
}
|
||||
|
||||
private async syncToServer(result: SyncResult): Promise<number[]> {
|
||||
const pendingChanges = await db.pending_changes
|
||||
.where('store')
|
||||
.equals(this.options.storeName)
|
||||
async getAll(): Promise<Transaction[]> {
|
||||
return await db.transactions.toArray();
|
||||
}
|
||||
|
||||
async getById(id: UUID): Promise<Transaction | null> {
|
||||
return (await db.transactions.get(id)) || null;
|
||||
}
|
||||
|
||||
async getByAccountId(accountId: UUID): Promise<Transaction[]> {
|
||||
return await db.transactions
|
||||
.where('account_id')
|
||||
.equals(accountId)
|
||||
.toArray();
|
||||
|
||||
const successfulChangeIds: number[] = [];
|
||||
|
||||
for (const change of pendingChanges) {
|
||||
try {
|
||||
const data = this.options.transformToServer
|
||||
? this.options.transformToServer(change.data)
|
||||
: change.data;
|
||||
|
||||
switch (change.operation) {
|
||||
case 'create':
|
||||
await axios.post(this.options.endpoint, data);
|
||||
result.inserted++;
|
||||
break;
|
||||
case 'update':
|
||||
try {
|
||||
await axios.patch(
|
||||
`${this.options.endpoint}/${change.data.id}`,
|
||||
data,
|
||||
);
|
||||
result.updated++;
|
||||
} catch (updateError) {
|
||||
if (
|
||||
updateError instanceof AxiosError &&
|
||||
updateError.response?.status === 404
|
||||
) {
|
||||
await axios.post(this.options.endpoint, data);
|
||||
result.inserted++;
|
||||
} else {
|
||||
throw updateError;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
try {
|
||||
await axios.delete(
|
||||
`${this.options.endpoint}/${change.data.id}`,
|
||||
);
|
||||
result.deleted++;
|
||||
} catch (deleteError) {
|
||||
if (
|
||||
deleteError instanceof AxiosError &&
|
||||
deleteError.response?.status === 404
|
||||
) {
|
||||
result.deleted++;
|
||||
} else {
|
||||
throw deleteError;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (change.id !== undefined) {
|
||||
successfulChangeIds.push(change.id);
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push(
|
||||
`Failed to sync ${change.operation} for ${this.options.storeName}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return successfulChangeIds;
|
||||
}
|
||||
|
||||
async createLocal<T extends IndexedDBRecord>(
|
||||
data: Omit<T, 'id' | 'created_at' | 'updated_at'>,
|
||||
): Promise<T> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const id = uuidv7();
|
||||
|
||||
const record = {
|
||||
...data,
|
||||
id,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
} as T;
|
||||
|
||||
const table = db[this.options.storeName];
|
||||
await table.put(record);
|
||||
await db.pending_changes.add({
|
||||
store: this.options.storeName,
|
||||
operation: 'create',
|
||||
data: record,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
async updateLocal<T extends IndexedDBRecord>(
|
||||
id: UUID,
|
||||
data: Partial<T>,
|
||||
): Promise<void> {
|
||||
const table = db[this.options.storeName];
|
||||
const existing = await table.get(id);
|
||||
|
||||
if (!existing) {
|
||||
throw new Error(
|
||||
`Record ${id} not found in ${this.options.storeName}`,
|
||||
);
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const updated = {
|
||||
...existing,
|
||||
...data,
|
||||
updated_at: timestamp,
|
||||
};
|
||||
|
||||
await table.put(updated);
|
||||
await db.pending_changes.add({
|
||||
store: this.options.storeName,
|
||||
operation: 'update',
|
||||
data: updated,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteLocal(id: UUID): Promise<void> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const table = db[this.options.storeName];
|
||||
await table.delete(id);
|
||||
await db.pending_changes.add({
|
||||
store: this.options.storeName,
|
||||
operation: 'delete',
|
||||
data: { id },
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
async getAll<T extends IndexedDBRecord>(): Promise<T[]> {
|
||||
const table = db[this.options.storeName];
|
||||
return (await table.toArray()) as T[];
|
||||
}
|
||||
|
||||
async getById<T extends IndexedDBRecord>(id: UUID): Promise<T | null> {
|
||||
const table = db[this.options.storeName];
|
||||
return ((await table.get(id)) as T) || null;
|
||||
}
|
||||
|
||||
isSyncing(): boolean {
|
||||
return this.syncInProgress;
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
await db.transactions.clear();
|
||||
await db.sync_metadata.delete(LAST_SYNC_KEY);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -205,6 +205,7 @@ export default function AccountShow({
|
|||
<ImportBalancesDrawer
|
||||
open={importBalancesOpen}
|
||||
onOpenChange={setImportBalancesOpen}
|
||||
accounts={accounts}
|
||||
accountId={account.id}
|
||||
onSuccess={handleBalanceUpdated}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Input } from '@/components/ui/input';
|
|||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
import { clearAllUserData } from '@/lib/user-session-storage';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
|
||||
interface RegisterProps {
|
||||
hideAuthButtons?: boolean;
|
||||
|
|
@ -24,7 +24,7 @@ export default function Register({ hideAuthButtons = false }: RegisterProps) {
|
|||
}, [hideAuthButtons]);
|
||||
|
||||
const handleBeforeSubmit = useCallback(async () => {
|
||||
await clearAllUserData();
|
||||
await transactionSyncService.clearAll();
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Head } from '@inertiajs/react';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import {
|
||||
Cell,
|
||||
ColumnDef,
|
||||
|
|
@ -13,7 +13,6 @@ import {
|
|||
useReactTable,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
|
|
@ -50,8 +49,6 @@ import {
|
|||
} from '@/components/ui/table';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { db } from '@/lib/dexie-db';
|
||||
import { accountSyncService } from '@/services/account-sync';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type Account, formatAccountType } from '@/types/account';
|
||||
|
||||
|
|
@ -76,7 +73,11 @@ function AccountActions({
|
|||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0" aria-label="Open menu">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -175,16 +176,19 @@ function AccountRow({
|
|||
);
|
||||
}
|
||||
|
||||
export default function Accounts() {
|
||||
const accounts = useLiveQuery(() => db.accounts.toArray(), []) || [];
|
||||
interface AccountsPageProps {
|
||||
accounts: Account[];
|
||||
}
|
||||
|
||||
export default function Accounts({ accounts }: AccountsPageProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
{},
|
||||
);
|
||||
|
||||
const handleAccountCreated = async () => {
|
||||
await accountSyncService.sync();
|
||||
const handleAccountCreated = () => {
|
||||
router.reload({ only: ['accounts'] });
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Account>[] = [
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Head } from '@inertiajs/react';
|
||||
import { Head, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
Cell,
|
||||
ColumnDef,
|
||||
|
|
@ -12,7 +12,6 @@ import {
|
|||
useReactTable,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
|
@ -51,10 +50,10 @@ import {
|
|||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { db } from '@/lib/dexie-db';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type AutomationRule, getRuleActions } from '@/types/automation-rule';
|
||||
import { getCategoryColorClasses } from '@/types/category';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
import { type Label } from '@/types/label';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
|
|
@ -63,7 +62,15 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||
},
|
||||
];
|
||||
|
||||
function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
|
||||
function AutomationRuleActions({
|
||||
rule,
|
||||
categories,
|
||||
labels,
|
||||
}: {
|
||||
rule: AutomationRule;
|
||||
categories: Category[];
|
||||
labels: Label[];
|
||||
}) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
|
|
@ -71,7 +78,11 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
|
|||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0" aria-label="Actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Actions"
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -92,6 +103,8 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
|
|||
|
||||
<EditAutomationRuleDialog
|
||||
rule={rule}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
|
|
@ -104,7 +117,15 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
|
|||
);
|
||||
}
|
||||
|
||||
function AutomationRuleRow({ row }: { row: Row<AutomationRule> }) {
|
||||
function AutomationRuleRow({
|
||||
row,
|
||||
categories,
|
||||
labels,
|
||||
}: {
|
||||
row: Row<AutomationRule>;
|
||||
categories: Category[];
|
||||
labels: Label[];
|
||||
}) {
|
||||
const rule = row.original;
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
|
@ -148,6 +169,8 @@ function AutomationRuleRow({ row }: { row: Row<AutomationRule> }) {
|
|||
|
||||
<EditAutomationRuleDialog
|
||||
rule={rule}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
|
|
@ -162,8 +185,13 @@ function AutomationRuleRow({ row }: { row: Row<AutomationRule> }) {
|
|||
|
||||
export default function AutomationRules() {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const rawRules =
|
||||
useLiveQuery(() => db.automation_rules.toArray(), []) || [];
|
||||
const { automationRules: rawRules } = usePage<{
|
||||
automationRules: AutomationRule[];
|
||||
}>().props;
|
||||
|
||||
// Get categories and labels from globally shared Inertia data
|
||||
const categories = usePage().props.categories as Category[];
|
||||
const labels = usePage().props.labels as Label[];
|
||||
const rules = useMemo(
|
||||
() =>
|
||||
rawRules.map((rule) => ({
|
||||
|
|
@ -276,7 +304,13 @@ export default function AutomationRules() {
|
|||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => <AutomationRuleActions rule={row.original} />,
|
||||
cell: ({ row }) => (
|
||||
<AutomationRuleActions
|
||||
rule={row.original}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -323,7 +357,11 @@ export default function AutomationRules() {
|
|||
}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<CreateAutomationRuleDialog disabled={!isKeySet} />
|
||||
<CreateAutomationRuleDialog
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
disabled={!isKeySet}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
|
|
@ -363,6 +401,8 @@ export default function AutomationRules() {
|
|||
<AutomationRuleRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Head } from '@inertiajs/react';
|
||||
import { Head, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
Cell,
|
||||
ColumnDef,
|
||||
|
|
@ -12,7 +12,6 @@ import {
|
|||
useReactTable,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
|
@ -49,7 +48,6 @@ import {
|
|||
} from '@/components/ui/table';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { db } from '@/lib/dexie-db';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
|
||||
|
|
@ -162,7 +160,7 @@ function CategoryRow({ row }: { row: Row<Category> }) {
|
|||
}
|
||||
|
||||
export default function Categories() {
|
||||
const categories = useLiveQuery(() => db.categories.toArray(), []) || [];
|
||||
const { categories } = usePage<{ categories: Category[] }>().props;
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'name', desc: false },
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Head } from '@inertiajs/react';
|
||||
import { Head, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
Cell,
|
||||
ColumnDef,
|
||||
|
|
@ -12,7 +12,6 @@ import {
|
|||
useReactTable,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { ArrowUpDown, MoreHorizontal, Tag } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
|
|
@ -48,7 +47,6 @@ import {
|
|||
} from '@/components/ui/table';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { db } from '@/lib/dexie-db';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { getLabelColorClasses, type Label } from '@/types/label';
|
||||
|
||||
|
|
@ -161,7 +159,7 @@ function LabelRow({ row }: { row: Row<Label> }) {
|
|||
}
|
||||
|
||||
export default function Labels() {
|
||||
const labels = useLiveQuery(() => db.labels.toArray(), []) || [];
|
||||
const { labels } = usePage<{ labels: Label[] }>().props;
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'name', desc: false },
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import { type Account, type Bank } from '@/types/account';
|
|||
import { type AutomationRule } from '@/types/automation-rule';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import {
|
||||
|
|
@ -97,6 +97,9 @@ export default function CategorizeTransactions({
|
|||
banks,
|
||||
}: Props) {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const { automationRules: sharedAutomationRules } = usePage<{
|
||||
automationRules: AutomationRule[];
|
||||
}>().props;
|
||||
|
||||
const transactionIds = useLiveQuery(
|
||||
async () => {
|
||||
|
|
@ -125,19 +128,16 @@ export default function CategorizeTransactions({
|
|||
const [encryptionKey, setEncryptionKey] = useState<CryptoKey | null>(null);
|
||||
const commandInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const automationRules = useLiveQuery(
|
||||
async () => {
|
||||
const rules = await db.automation_rules.toArray();
|
||||
return rules.map((rule) => ({
|
||||
const automationRules = useMemo(
|
||||
() =>
|
||||
sharedAutomationRules.map((rule) => ({
|
||||
...rule,
|
||||
rules_json:
|
||||
typeof rule.rules_json === 'string'
|
||||
? JSON.parse(rule.rules_json)
|
||||
: rule.rules_json,
|
||||
})) as AutomationRule[];
|
||||
},
|
||||
[],
|
||||
[],
|
||||
})) as AutomationRule[],
|
||||
[sharedAutomationRules],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { useSyncContext } from '@/contexts/sync-context';
|
||||
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
||||
import { decrypt, encrypt, importKey } from '@/lib/crypto';
|
||||
import { consoleDebug } from '@/lib/debug';
|
||||
|
|
@ -56,10 +57,10 @@ import { db } from '@/lib/dexie-db';
|
|||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { evaluateRules } from '@/lib/rule-engine';
|
||||
import { appendNoteIfNotPresent, cn } from '@/lib/utils';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type AutomationRule } from '@/types/automation-rule';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Label } from '@/types/label';
|
||||
import {
|
||||
|
|
@ -79,6 +80,7 @@ interface Props {
|
|||
accounts: Account[];
|
||||
banks: Bank[];
|
||||
labels: Label[];
|
||||
automationRules: AutomationRule[];
|
||||
}
|
||||
|
||||
const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility';
|
||||
|
|
@ -357,8 +359,15 @@ export default function Transactions({
|
|||
accounts,
|
||||
banks,
|
||||
labels: initialLabels,
|
||||
automationRules,
|
||||
}: Props) {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const { sync } = useSyncContext();
|
||||
|
||||
// Sync transactions when page loads
|
||||
useEffect(() => {
|
||||
sync();
|
||||
}, [sync]);
|
||||
|
||||
const transactionIds = useLiveQuery(
|
||||
async () => {
|
||||
|
|
@ -387,7 +396,7 @@ export default function Transactions({
|
|||
const [filters, setFilters] = useState<Filters>(() =>
|
||||
parseFiltersFromURL(),
|
||||
);
|
||||
const labels = useLiveQuery(() => db.labels.toArray(), [], initialLabels);
|
||||
const labels = initialLabels;
|
||||
const [editTransaction, setEditTransaction] =
|
||||
useState<DecryptedTransaction | null>(null);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
|
|
@ -816,7 +825,7 @@ export default function Transactions({
|
|||
consoleDebug('✓ Encryption key found');
|
||||
|
||||
const key = await importKey(keyString);
|
||||
const rules = await automationRuleSyncService.getAll();
|
||||
const rules = automationRules;
|
||||
consoleDebug(`Found ${rules.length} automation rules`);
|
||||
|
||||
if (rules.length === 0) {
|
||||
|
|
@ -917,7 +926,14 @@ export default function Transactions({
|
|||
consoleDebug('=== Re-evaluation complete ===');
|
||||
}
|
||||
},
|
||||
[isKeySet, categories, accounts, banks, updateTransaction],
|
||||
[
|
||||
isKeySet,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
updateTransaction,
|
||||
automationRules,
|
||||
],
|
||||
);
|
||||
|
||||
async function handleBulkReEvaluateRules() {
|
||||
|
|
@ -944,7 +960,7 @@ export default function Transactions({
|
|||
consoleDebug('✓ Encryption key found');
|
||||
|
||||
const key = await importKey(keyString);
|
||||
const rules = await automationRuleSyncService.getAll();
|
||||
const rules = automationRules;
|
||||
consoleDebug(`Found ${rules.length} automation rules`);
|
||||
|
||||
if (rules.length === 0) {
|
||||
|
|
@ -1211,6 +1227,9 @@ export default function Transactions({
|
|||
setDeleteTransaction(null);
|
||||
setIsBulkDeleteMode(false);
|
||||
setRowSelection({});
|
||||
|
||||
// Sync to update IndexedDB
|
||||
sync();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete transaction:', error);
|
||||
} finally {
|
||||
|
|
@ -1282,6 +1301,9 @@ export default function Transactions({
|
|||
|
||||
setRowSelection({});
|
||||
setIsSelectingAll(false);
|
||||
|
||||
// Sync to update IndexedDB
|
||||
sync();
|
||||
} catch (error) {
|
||||
console.error('Failed to update transactions:', error);
|
||||
toast.error('Failed to update transactions');
|
||||
|
|
@ -1329,6 +1351,9 @@ export default function Transactions({
|
|||
setDeleteTransaction(null);
|
||||
setIsBulkDeleteMode(false);
|
||||
setRowSelection({});
|
||||
|
||||
// Sync to update IndexedDB
|
||||
sync();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete transactions:', error);
|
||||
} finally {
|
||||
|
|
@ -1430,6 +1455,9 @@ export default function Transactions({
|
|||
|
||||
setRowSelection({});
|
||||
setIsSelectingAll(false);
|
||||
|
||||
// Sync to update IndexedDB
|
||||
sync();
|
||||
} catch (error) {
|
||||
console.error('Failed to update transactions with labels:', error);
|
||||
toast.error('Failed to update transactions with labels');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -1,26 +1,9 @@
|
|||
import { encrypt, importKey } from '@/lib/crypto';
|
||||
import { db } from '@/lib/dexie-db';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { SyncManager } from '@/lib/sync-manager';
|
||||
import { TransactionSyncManager } from '@/lib/sync-manager';
|
||||
import type { Transaction } from '@/types/transaction';
|
||||
import type { UUID } from '@/types/uuid';
|
||||
import { uuidv7 } from 'uuidv7';
|
||||
|
||||
export interface Transaction {
|
||||
id: UUID;
|
||||
user_id: UUID;
|
||||
account_id: UUID;
|
||||
category_id: UUID | null;
|
||||
description: string;
|
||||
description_iv: string;
|
||||
transaction_date: string;
|
||||
amount: string;
|
||||
currency_code: string;
|
||||
notes: string | null;
|
||||
notes_iv: string | null;
|
||||
label_ids?: UUID[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
import axios from 'axios';
|
||||
|
||||
interface TransactionUpdateData extends Partial<Transaction> {
|
||||
label_ids?: string[];
|
||||
|
|
@ -37,15 +20,22 @@ interface TransactionFilters {
|
|||
searchText?: string;
|
||||
}
|
||||
|
||||
function getCsrfToken(): string {
|
||||
return decodeURIComponent(
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('XSRF-TOKEN='))
|
||||
?.split('=')[1] || '',
|
||||
);
|
||||
}
|
||||
|
||||
class TransactionSyncService {
|
||||
private syncManager: SyncManager;
|
||||
private syncManager: TransactionSyncManager;
|
||||
|
||||
constructor() {
|
||||
this.syncManager = new SyncManager({
|
||||
storeName: 'transactions',
|
||||
this.syncManager = new TransactionSyncManager({
|
||||
endpoint: '/api/sync/transactions',
|
||||
transformFromServer: (data) => {
|
||||
// Extract label_ids from labels array if present
|
||||
const label_ids = data.labels?.map((l: { id: string }) => l.id);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { labels, ...rest } = data;
|
||||
|
|
@ -66,74 +56,51 @@ class TransactionSyncService {
|
|||
}
|
||||
|
||||
async getAll(): Promise<Transaction[]> {
|
||||
return await this.syncManager.getAll<Transaction>();
|
||||
return await this.syncManager.getAll();
|
||||
}
|
||||
|
||||
async getById(id: UUID): Promise<Transaction | null> {
|
||||
return (await db.transactions.get(id)) || null;
|
||||
return await this.syncManager.getById(id);
|
||||
}
|
||||
|
||||
async getByAccountId(accountId: UUID): Promise<Transaction[]> {
|
||||
try {
|
||||
const allTransactions = await this.getAll();
|
||||
return allTransactions.filter((t) => t.account_id === accountId);
|
||||
} catch (error) {
|
||||
console.warn('Failed to get transactions from IndexedDB:', error);
|
||||
return [];
|
||||
}
|
||||
return await this.syncManager.getByAccountId(accountId);
|
||||
}
|
||||
|
||||
async create(
|
||||
data: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>,
|
||||
): Promise<Transaction> {
|
||||
return await this.syncManager.createLocal<Transaction>(
|
||||
data as Omit<Transaction, 'id' | 'created_at' | 'updated_at'> & {
|
||||
id?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
},
|
||||
);
|
||||
const response = await axios.post('/api/sync/transactions', data);
|
||||
const serverData = response.data.data;
|
||||
|
||||
const label_ids = serverData.labels?.map((l: { id: string }) => l.id);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { labels, ...rest } = serverData;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
transaction_date: String(serverData.transaction_date).slice(0, 10),
|
||||
label_ids: label_ids || [],
|
||||
} as Transaction;
|
||||
}
|
||||
|
||||
async createMany(
|
||||
transactions: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>[],
|
||||
): Promise<Transaction[]> {
|
||||
try {
|
||||
const timestamp = new Date().toISOString();
|
||||
const created: Transaction[] = [];
|
||||
const created: Transaction[] = [];
|
||||
|
||||
for (const data of transactions) {
|
||||
const record = {
|
||||
...data,
|
||||
id: uuidv7(),
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
} as Transaction;
|
||||
|
||||
await db.transactions.put(record);
|
||||
await db.pending_changes.add({
|
||||
store: 'transactions',
|
||||
operation: 'create',
|
||||
data: record,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
created.push(record);
|
||||
}
|
||||
|
||||
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.',
|
||||
);
|
||||
for (const data of transactions) {
|
||||
const transaction = await this.create(data);
|
||||
created.push(transaction);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: TransactionUpdateData,
|
||||
): Promise<Transaction | void> {
|
||||
): Promise<Transaction> {
|
||||
const existing = await this.getById(id);
|
||||
|
||||
if (!existing) {
|
||||
|
|
@ -141,181 +108,67 @@ class TransactionSyncService {
|
|||
}
|
||||
|
||||
const { label_ids, ...transactionData } = data;
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// If label_ids are provided, we need to sync with the server immediately
|
||||
if (label_ids !== undefined) {
|
||||
const csrfToken = decodeURIComponent(
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('XSRF-TOKEN='))
|
||||
?.split('=')[1] || '',
|
||||
);
|
||||
const response = await fetch(`/api/sync/transactions/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
...existing,
|
||||
...transactionData,
|
||||
label_ids,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/sync/transactions/${id}`, {
|
||||
method: 'PATCH',
|
||||
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;
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update transaction');
|
||||
}
|
||||
|
||||
// No label_ids, use the normal offline-first approach
|
||||
const updated = {
|
||||
...existing,
|
||||
...transactionData,
|
||||
updated_at: timestamp,
|
||||
};
|
||||
const result = await response.json();
|
||||
const serverData = result.data;
|
||||
|
||||
await db.transactions.put(updated);
|
||||
await db.pending_changes.add({
|
||||
store: 'transactions',
|
||||
operation: 'update',
|
||||
data: updated,
|
||||
timestamp,
|
||||
});
|
||||
const serverLabelIds = serverData.labels?.map(
|
||||
(l: { id: string }) => l.id,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { labels: _labels, ...restServerData } = serverData;
|
||||
|
||||
return {
|
||||
...restServerData,
|
||||
transaction_date: String(serverData.transaction_date).slice(0, 10),
|
||||
label_ids: serverLabelIds || [],
|
||||
} as Transaction;
|
||||
}
|
||||
|
||||
async updateMany(
|
||||
ids: string[],
|
||||
data: TransactionUpdateData,
|
||||
): Promise<void> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const { label_ids, ...transactionData } = data;
|
||||
|
||||
if (label_ids !== undefined) {
|
||||
try {
|
||||
const csrfToken = decodeURIComponent(
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('XSRF-TOKEN='))
|
||||
?.split('=')[1] || '',
|
||||
);
|
||||
|
||||
const response = await fetch('/transactions/bulk', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': csrfToken,
|
||||
},
|
||||
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,
|
||||
const response = await fetch('/transactions/bulk', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
transaction_ids: ids,
|
||||
label_ids: label_ids,
|
||||
...transactionData,
|
||||
updated_at: timestamp,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
updates.push(updated);
|
||||
pendingChanges.push({
|
||||
store: 'transactions',
|
||||
operation: 'update',
|
||||
data: updated,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
await db.transactions.bulkPut(updates);
|
||||
await db.pending_changes.bulkAdd(pendingChanges);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to bulk update transactions');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -350,215 +203,45 @@ class TransactionSyncService {
|
|||
requestFilters.label_ids = filters.labelIds;
|
||||
}
|
||||
|
||||
try {
|
||||
const csrfToken = decodeURIComponent(
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('XSRF-TOKEN='))
|
||||
?.split('=')[1] || '',
|
||||
);
|
||||
const response = await fetch('/transactions/bulk', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
filters: requestFilters,
|
||||
label_ids: label_ids,
|
||||
...transactionData,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await fetch('/transactions/bulk', {
|
||||
method: 'PATCH',
|
||||
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;
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to bulk update transactions by filters');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.count || 0;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const transaction = await this.getById(id);
|
||||
|
||||
if (!transaction) {
|
||||
throw new Error('Transaction not found');
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
await db.transactions.delete(transaction.id);
|
||||
await db.pending_changes.add({
|
||||
store: 'transactions',
|
||||
operation: 'delete',
|
||||
data: transaction,
|
||||
timestamp,
|
||||
});
|
||||
await axios.delete(`/api/sync/transactions/${id}`);
|
||||
}
|
||||
|
||||
async updateManyIndividual(
|
||||
updates: Array<{ id: string; data: TransactionUpdateData }>,
|
||||
): Promise<void> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const dbUpdates: Transaction[] = [];
|
||||
const pendingChanges: Array<{
|
||||
store: string;
|
||||
operation: string;
|
||||
data: Transaction;
|
||||
timestamp: string;
|
||||
}> = [];
|
||||
|
||||
for (const { id, data } of updates) {
|
||||
const existing = await this.getById(id);
|
||||
|
||||
if (!existing) {
|
||||
console.warn(`Transaction ${id} not found, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { label_ids, ...transactionData } = data;
|
||||
|
||||
const updated = {
|
||||
...existing,
|
||||
...transactionData,
|
||||
updated_at: timestamp,
|
||||
} as Transaction;
|
||||
|
||||
dbUpdates.push(updated);
|
||||
pendingChanges.push({
|
||||
store: 'transactions',
|
||||
operation: 'update',
|
||||
data: updated,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
if (dbUpdates.length > 0) {
|
||||
await db.transactions.bulkPut(dbUpdates);
|
||||
await db.pending_changes.bulkAdd(pendingChanges);
|
||||
await this.update(id, data);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMany(ids: string[]): Promise<void> {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
for (const id of ids) {
|
||||
const transaction = await this.getById(id);
|
||||
|
||||
if (!transaction) {
|
||||
console.warn(`Transaction ${id} not found, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.transactions.delete(transaction.id);
|
||||
await db.pending_changes.add({
|
||||
store: 'transactions',
|
||||
operation: 'delete',
|
||||
data: transaction,
|
||||
timestamp,
|
||||
});
|
||||
await this.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -588,10 +271,6 @@ class TransactionSyncService {
|
|||
return txDate >= minDate && txDate <= maxDate;
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Checking duplicates for ${transactions.length} transactions. Found ${transactionsInRange.length} existing transactions between ${minDate} and ${maxDate}`,
|
||||
);
|
||||
|
||||
const keyString = getStoredKey();
|
||||
if (!keyString) {
|
||||
console.warn('No encryption key found for duplicate check');
|
||||
|
|
@ -617,12 +296,7 @@ class TransactionSyncService {
|
|||
.trim()
|
||||
.replace(/\s+/g, ' '),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to decrypt transaction:',
|
||||
t.id,
|
||||
error,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
|
|
@ -632,11 +306,7 @@ class TransactionSyncService {
|
|||
(t) => t !== null,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Successfully decrypted ${validDecryptedTransactions.length} transactions`,
|
||||
);
|
||||
|
||||
const results = transactions.map((importingTx) => {
|
||||
return transactions.map((importingTx) => {
|
||||
const normalizedDescription = importingTx.description
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
|
|
@ -651,13 +321,6 @@ class TransactionSyncService {
|
|||
existing.description === normalizedDescription,
|
||||
);
|
||||
});
|
||||
|
||||
const duplicateCount = results.filter((r) => r).length;
|
||||
console.log(
|
||||
`Found ${duplicateCount} duplicates out of ${transactions.length} transactions`,
|
||||
);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Duplicate check failed, assuming no duplicates:',
|
||||
|
|
@ -686,6 +349,10 @@ class TransactionSyncService {
|
|||
isSyncing(): boolean {
|
||||
return this.syncManager.isSyncing();
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
await this.syncManager.clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
export const transactionSyncService = new TransactionSyncService();
|
||||
|
|
|
|||
|
|
@ -3,13 +3,8 @@
|
|||
use App\Http\Controllers\AccountBalanceController;
|
||||
use App\Http\Controllers\Api\CashflowAnalyticsController;
|
||||
use App\Http\Controllers\Api\DashboardAnalyticsController;
|
||||
use App\Http\Controllers\Api\ImportDataController;
|
||||
use App\Http\Controllers\EncryptionController;
|
||||
use App\Http\Controllers\Sync\AccountBalanceSyncController;
|
||||
use App\Http\Controllers\Sync\AccountSyncController;
|
||||
use App\Http\Controllers\Sync\AutomationRuleSyncController;
|
||||
use App\Http\Controllers\Sync\BankSyncController;
|
||||
use App\Http\Controllers\Sync\CategorySyncController;
|
||||
use App\Http\Controllers\Sync\LabelSyncController;
|
||||
use App\Http\Controllers\Sync\TransactionSyncController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
|
|
@ -18,27 +13,21 @@ Route::middleware(['web', 'auth'])->group(function () {
|
|||
Route::post('encryption/setup', [EncryptionController::class, 'setup']);
|
||||
Route::get('encryption/message', [EncryptionController::class, 'getMessage']);
|
||||
|
||||
// Sync
|
||||
Route::prefix('sync')->group(function () {
|
||||
Route::get('categories', [CategorySyncController::class, 'index']);
|
||||
Route::get('labels', [LabelSyncController::class, 'index']);
|
||||
Route::get('accounts', [AccountSyncController::class, 'index']);
|
||||
Route::get('banks', [BankSyncController::class, 'index']);
|
||||
Route::get('automation-rules', [AutomationRuleSyncController::class, 'index']);
|
||||
// Import Data (for import drawers)
|
||||
Route::get('import/data', [ImportDataController::class, 'index']);
|
||||
|
||||
// Transaction Sync (for IndexedDB client-side search)
|
||||
Route::prefix('sync')->group(function () {
|
||||
Route::get('transactions', [TransactionSyncController::class, 'index']);
|
||||
Route::post('transactions', [TransactionSyncController::class, 'store']);
|
||||
Route::patch('transactions/{transaction}', [TransactionSyncController::class, 'update']);
|
||||
Route::delete('transactions/{transaction}', [TransactionSyncController::class, 'destroy']);
|
||||
|
||||
Route::get('account-balances', [AccountBalanceSyncController::class, 'index']);
|
||||
Route::post('account-balances', [AccountBalanceSyncController::class, 'store']);
|
||||
Route::patch('account-balances/{accountBalance}', [AccountBalanceSyncController::class, 'update']);
|
||||
});
|
||||
|
||||
// Account Balances
|
||||
Route::put('accounts/{account}/balance/current', [AccountBalanceController::class, 'updateCurrent'])->name('api.accounts.balance.update-current');
|
||||
Route::get('accounts/{account}/balances', [AccountBalanceController::class, 'index'])->name('api.accounts.balances.index');
|
||||
Route::post('accounts/{account}/balances', [AccountBalanceController::class, 'store'])->name('api.accounts.balances.store');
|
||||
Route::delete('accounts/{account}/balances/{accountBalance}', [AccountBalanceController::class, 'destroy'])->name('api.accounts.balances.destroy');
|
||||
|
||||
// Dashboard Analytics
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ Route::middleware('auth')->group(function () {
|
|||
Route::patch('settings/accounts/{account}', [AccountController::class, 'update'])->name('accounts.update');
|
||||
Route::delete('settings/accounts/{account}', [AccountController::class, 'destroy'])->name('accounts.destroy');
|
||||
|
||||
Route::get('settings/banks', [BankController::class, 'index'])->name('banks.index');
|
||||
Route::post('settings/banks', [BankController::class, 'store'])->name('banks.store');
|
||||
|
||||
Route::get('settings/categories', [CategoryController::class, 'index'])->name('categories.index');
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ it('shows existing accounts in list', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/accounts');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Bank accounts')
|
||||
->waitForText('Test Bank')
|
||||
|
|
@ -125,6 +126,7 @@ it('can filter accounts by name', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/accounts');
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
$page->assertSee('Bank accounts')
|
||||
->waitForText('Test Bank')
|
||||
|
|
@ -140,12 +142,13 @@ it('can edit an existing account via dropdown menu', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/accounts');
|
||||
$page->wait(1);
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
// Create account via UI to ensure it syncs to IndexedDB
|
||||
createAccountViaUI($page, 'Old Account Name', 'Edit Bank', 'Checking', 'USD');
|
||||
|
||||
$page->navigate('/settings/accounts')->wait(1);
|
||||
$page->navigate('/settings/accounts')->wait(3);
|
||||
|
||||
$page->assertSee('Bank accounts')
|
||||
->click('button[aria-label="Open menu"]')
|
||||
|
|
@ -157,7 +160,7 @@ it('can edit an existing account via dropdown menu', function () {
|
|||
->click('button[type="submit"]:has-text("Update")')
|
||||
->wait(2);
|
||||
|
||||
$page->navigate('/settings/accounts')->wait(1);
|
||||
$page->navigate('/settings/accounts')->wait(3);
|
||||
|
||||
$page->assertSee('Updated Account Name')
|
||||
->assertNoJavascriptErrors();
|
||||
|
|
@ -170,12 +173,13 @@ it('can delete an account via dropdown menu', function () {
|
|||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/accounts');
|
||||
$page->wait(1);
|
||||
$this->setupEncryptionKey($page);
|
||||
|
||||
// Create account via UI to ensure it syncs to IndexedDB
|
||||
createAccountViaUI($page, 'Account To Delete', 'Delete Bank', 'Checking', 'USD');
|
||||
|
||||
$page->navigate('/settings/accounts')->wait(1);
|
||||
$page->navigate('/settings/accounts')->wait(3);
|
||||
|
||||
$page->assertSee('Bank accounts')
|
||||
->assertSee('Account To Delete')
|
||||
|
|
@ -189,7 +193,7 @@ it('can delete an account via dropdown menu', function () {
|
|||
->click('button[type="submit"]:has-text("Delete")')
|
||||
->wait(2);
|
||||
|
||||
$page->navigate('/settings/accounts')->wait(1);
|
||||
$page->navigate('/settings/accounts')->wait(3);
|
||||
|
||||
$page->assertDontSee('Account To Delete')
|
||||
->assertNoJavascriptErrors();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
|
@ -12,9 +12,10 @@ test('user can view their automation rules', function () {
|
|||
$response = $this->actingAs($user)->get(route('automation-rules.index'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('settings/automation-rules')
|
||||
->has('rules', 1)
|
||||
$response->assertInertia(
|
||||
fn ($page) => $page
|
||||
->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->assertSuccessful();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('rules', 3)
|
||||
->where('rules.0.priority', 10)
|
||||
->where('rules.1.priority', 20)
|
||||
->where('rules.2.priority', 30)
|
||||
$response->assertInertia(
|
||||
fn ($page) => $page
|
||||
->has('automationRules', 3)
|
||||
->where('automationRules.0.priority', 10)
|
||||
->where('automationRules.1.priority', 20)
|
||||
->where('automationRules.2.priority', 30)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@
|
|||
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Category;
|
||||
use App\Models\Label;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
|
||||
|
|
@ -424,119 +421,6 @@ describe('Cross-Resource IDOR Protection', function () {
|
|||
});
|
||||
|
||||
describe('Sync Endpoints IDOR Protection', function () {
|
||||
describe('AccountBalanceSyncController', function () {
|
||||
it('cannot list account balances from another user account', function () {
|
||||
$attacker = User::factory()->create();
|
||||
$attackerAccount = Account::factory()->for($attacker)->create();
|
||||
$attackerBalance = AccountBalance::factory()->for($attackerAccount)->create();
|
||||
|
||||
$victim = User::factory()->create();
|
||||
$victimAccount = Account::factory()->for($victim)->create();
|
||||
$victimBalances = AccountBalance::factory()->for($victimAccount)->count(3)->create();
|
||||
|
||||
actingAs($attacker);
|
||||
|
||||
$response = $this->getJson('/api/sync/account-balances');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$balances = $response->json('data');
|
||||
expect($balances)->toBeArray();
|
||||
expect(count($balances))->toBe(1);
|
||||
expect($balances[0]['id'])->toBe($attackerBalance->id);
|
||||
expect($balances[0]['id'])->not->toBeIn($victimBalances->pluck('id')->toArray());
|
||||
});
|
||||
|
||||
it('cannot list account balances from another user even with since parameter', function () {
|
||||
$attacker = User::factory()->create();
|
||||
$attackerAccount = Account::factory()->for($attacker)->create();
|
||||
$attackerBalance = AccountBalance::factory()->for($attackerAccount)->create([
|
||||
'updated_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$victim = User::factory()->create();
|
||||
$victimAccount = Account::factory()->for($victim)->create();
|
||||
$victimBalance = AccountBalance::factory()->for($victimAccount)->create([
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
actingAs($attacker);
|
||||
|
||||
$response = $this->getJson('/api/sync/account-balances?since='.now()->subDays(2)->toISOString());
|
||||
|
||||
$response->assertSuccessful();
|
||||
$balances = $response->json('data');
|
||||
expect($balances)->toBeArray();
|
||||
$balanceIds = collect($balances)->pluck('id')->toArray();
|
||||
expect($balanceIds)->toContain($attackerBalance->id);
|
||||
expect($balanceIds)->not->toContain($victimBalance->id);
|
||||
});
|
||||
|
||||
it('cannot create account balance for another user account', function () {
|
||||
$attacker = User::factory()->create();
|
||||
$victim = User::factory()->create();
|
||||
$victimAccount = Account::factory()->for($victim)->create();
|
||||
|
||||
actingAs($attacker);
|
||||
|
||||
$response = $this->postJson('/api/sync/account-balances', [
|
||||
'account_id' => $victimAccount->id,
|
||||
'balance_date' => now()->format('Y-m-d'),
|
||||
'balance' => 9999999,
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
expect($response->json('errors.account_id'))->toBeArray();
|
||||
|
||||
$this->assertDatabaseMissing('account_balances', [
|
||||
'account_id' => $victimAccount->id,
|
||||
'balance' => 9999999,
|
||||
]);
|
||||
});
|
||||
|
||||
it('cannot update account balance from another user account', function () {
|
||||
$attacker = User::factory()->create();
|
||||
$attackerAccount = Account::factory()->for($attacker)->create();
|
||||
$victim = User::factory()->create();
|
||||
$victimAccount = Account::factory()->for($victim)->create();
|
||||
$victimBalance = AccountBalance::factory()->for($victimAccount)->create([
|
||||
'balance' => 1000000,
|
||||
]);
|
||||
|
||||
actingAs($attacker);
|
||||
|
||||
$response = $this->patchJson("/api/sync/account-balances/{$victimBalance->id}", [
|
||||
'account_id' => $attackerAccount->id,
|
||||
'balance_date' => $victimBalance->balance_date->format('Y-m-d'),
|
||||
'balance' => 9999999,
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'id' => $victimBalance->id,
|
||||
'balance' => 1000000,
|
||||
'account_id' => $victimAccount->id,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AccountSyncController', function () {
|
||||
it('cannot list accounts from another user', function () {
|
||||
$attacker = User::factory()->create();
|
||||
$victim = User::factory()->create();
|
||||
Account::factory()->for($victim)->count(3)->create();
|
||||
|
||||
actingAs($attacker);
|
||||
|
||||
$response = $this->getJson('/api/sync/accounts');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$accounts = $response->json('data');
|
||||
expect($accounts)->toBeArray();
|
||||
expect(count($accounts))->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransactionSyncController', function () {
|
||||
it('cannot list transactions from another user', function () {
|
||||
$attacker = User::factory()->onboarded()->create();
|
||||
|
|
@ -735,75 +619,4 @@ describe('Sync Endpoints IDOR Protection', function () {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CategorySyncController', function () {
|
||||
it('cannot list categories from another user', function () {
|
||||
$attacker = User::factory()->create();
|
||||
$victim = User::factory()->create();
|
||||
Category::factory()->for($victim)->count(3)->create();
|
||||
|
||||
actingAs($attacker);
|
||||
|
||||
$response = $this->getJson('/api/sync/categories');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$categories = $response->json('data');
|
||||
expect($categories)->toBeArray();
|
||||
expect(count($categories))->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LabelSyncController', function () {
|
||||
it('cannot list labels from another user', function () {
|
||||
$attacker = User::factory()->create();
|
||||
$victim = User::factory()->create();
|
||||
Label::factory()->for($victim)->count(3)->create();
|
||||
|
||||
actingAs($attacker);
|
||||
|
||||
$response = $this->getJson('/api/sync/labels');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$labels = $response->json('data');
|
||||
expect($labels)->toBeArray();
|
||||
expect(count($labels))->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BankSyncController', function () {
|
||||
it('only returns global banks or user banks', function () {
|
||||
$attacker = User::factory()->create();
|
||||
$victim = User::factory()->create();
|
||||
$victimBank = Bank::factory()->for($victim)->create();
|
||||
$globalBank = Bank::factory()->create(['user_id' => null]);
|
||||
|
||||
actingAs($attacker);
|
||||
|
||||
$response = $this->getJson('/api/sync/banks');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$banks = $response->json('data');
|
||||
expect($banks)->toBeArray();
|
||||
$bankIds = collect($banks)->pluck('id')->toArray();
|
||||
expect($bankIds)->toContain($globalBank->id);
|
||||
expect($bankIds)->not->toContain($victimBank->id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AutomationRuleSyncController', function () {
|
||||
it('cannot list automation rules from another user', function () {
|
||||
$attacker = User::factory()->create();
|
||||
$victim = User::factory()->create();
|
||||
AutomationRule::factory()->for($victim)->count(3)->create();
|
||||
|
||||
actingAs($attacker);
|
||||
|
||||
$response = $this->getJson('/api/sync/automation-rules');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$rules = $response->json();
|
||||
expect($rules)->toBeArray();
|
||||
expect(count($rules))->toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -15,6 +15,8 @@ pest()->extend(Tests\TestCase::class)
|
|||
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
|
||||
->in('Feature', 'Browser');
|
||||
|
||||
pest()->browser()->timeout(15000);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expectations
|
||||
|
|
|
|||
|
|
@ -30,10 +30,16 @@ abstract class TestCase extends BaseTestCase
|
|||
protected function setupEncryptionKey($page, ?string $key = null, bool $reload = true): void
|
||||
{
|
||||
$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) {
|
||||
$currentUrl = $page->url();
|
||||
$page->navigate($currentUrl)->wait(1);
|
||||
$page->navigate($currentUrl)->wait(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue