Sync transactions

This commit is contained in:
Víctor Falcón 2025-11-08 14:17:16 +00:00
parent dd0f1380d4
commit faf6d9146e
13 changed files with 494 additions and 15 deletions

View File

@ -43,6 +43,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `bun run build`, `bun run dev`, or `composer run dev`. Ask them.
- Don't run `bun run build` by yourself, the user is usually already running `bun run dev`.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
@ -203,7 +204,7 @@ Route::get('/users', function () {
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `bun run build` or ask the user to run `bun run dev` or `composer run dev`.
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can ask the user to run `bun run dev` or `composer run dev`.
=== laravel/v12 rules ===

View File

@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\Sync;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreTransactionRequest;
use App\Models\Transaction;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TransactionSyncController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Transaction::query()
->where('user_id', $request->user()->id);
if ($request->has('since')) {
$query->where('updated_at', '>', $request->input('since'));
}
$transactions = $query->orderBy('updated_at', 'asc')->get();
return response()->json([
'data' => $transactions,
]);
}
public function store(StoreTransactionRequest $request): JsonResponse
{
$data = $request->validated();
// Create transaction with provided ID if available
$transaction = new Transaction([
...$data,
'user_id' => $request->user()->id,
]);
// If ID is provided, use it; otherwise Laravel will generate UUID v7
if (isset($data['id'])) {
$transaction->id = $data['id'];
$transaction->exists = false;
}
$transaction->save();
return response()->json([
'data' => $transaction,
], 201);
}
public function update(StoreTransactionRequest $request, Transaction $transaction): JsonResponse
{
// Ensure user owns this transaction
if ($transaction->user_id !== $request->user()->id) {
abort(403, 'Unauthorized');
}
$data = $request->validated();
unset($data['id']); // Don't allow ID changes
$transaction->update($data);
return response()->json([
'data' => $transaction->fresh(),
]);
}
public function destroy(Transaction $transaction): JsonResponse
{
// Ensure user owns this transaction
if ($transaction->user_id !== request()->user()->id) {
abort(403, 'Unauthorized');
}
$transaction->delete();
return response()->json([
'message' => 'Transaction deleted successfully',
]);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreTransactionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'id' => ['sometimes', 'uuid'],
'account_id' => ['required', 'exists:accounts,id'],
'category_id' => ['nullable', 'exists:categories,id'],
'description' => ['required', 'string'],
'description_iv' => ['required', 'string', 'size:16'],
'transaction_date' => ['required', 'date'],
'amount' => ['required', 'numeric'],
'currency_code' => ['required', 'string', 'size:3'],
'notes' => ['nullable', 'string'],
'notes_iv' => ['nullable', 'string', 'size:16'],
];
}
public function messages(): array
{
return [
'account_id.required' => 'The account is required.',
'account_id.exists' => 'The selected account does not exist.',
'category_id.exists' => 'The selected category does not exist.',
'description.required' => 'The description is required.',
'description_iv.required' => 'The description IV is required.',
'description_iv.size' => 'The description IV must be exactly 16 characters.',
'transaction_date.required' => 'The transaction date is required.',
'transaction_date.date' => 'The transaction date must be a valid date.',
'amount.required' => 'The amount is required.',
'amount.numeric' => 'The amount must be a number.',
'currency_code.required' => 'The currency code is required.',
'currency_code.size' => 'The currency code must be exactly 3 characters.',
'notes_iv.size' => 'The notes IV must be exactly 16 characters.',
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Transaction extends Model
{
/** @use HasFactory<\Database\Factories\TransactionFactory> */
use HasFactory;
use HasFactory, HasUuids;
protected $fillable = [
'user_id',
@ -46,4 +47,9 @@ class Transaction extends Model
{
return $this->belongsTo(Category::class);
}
public function newUniqueId(): string
{
return (string) \Illuminate\Support\Str::uuid7();
}
}

View File

@ -41,6 +41,7 @@
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2",
"uuidv7": "^1.0.2",
"vaul": "^1.1.2",
"vite": "^7.0.4",
"xlsx": "^0.18.5",
@ -1048,6 +1049,8 @@
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
"uuidv7": ["uuidv7@1.0.2", "", { "bin": { "uuidv7": "cli.js" } }, "sha512-8JQkH4ooXnm1JCIhqTMbtmdnYEn6oKukBxHn1Ic9878jMkL7daTI7anTExfY18VRCX7tcdn5quzvCb6EWrR8PA=="],
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"vite": ["vite@7.1.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ=="],

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Drop the existing id column and recreate it as UUID
Schema::table('transactions', function (Blueprint $table) {
// Drop the existing auto-increment id
$table->dropColumn('id');
});
Schema::table('transactions', function (Blueprint $table) {
// Add new UUID id as primary key
$table->uuid('id')->primary()->first();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->dropColumn('id');
});
Schema::table('transactions', function (Blueprint $table) {
$table->id()->first();
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->dropForeign(['category_id']);
$table->foreignId('category_id')->nullable()->change()->constrained()->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->dropForeign(['category_id']);
$table->foreignId('category_id')->nullable(false)->change()->constrained()->onDelete('cascade');
});
}
};

View File

@ -64,6 +64,7 @@
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2",
"uuidv7": "^1.0.2",
"vaul": "^1.1.2",
"vite": "^7.0.4",
"xlsx": "^0.18.5"

View File

@ -10,6 +10,7 @@ import { useOnlineStatus } from '@/hooks/use-online-status';
import { categorySyncService } from '@/services/category-sync';
import { accountSyncService } from '@/services/account-sync';
import { bankSyncService } from '@/services/bank-sync';
import { transactionSyncService } from '@/services/transaction-sync';
export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error';
@ -46,17 +47,19 @@ export function SyncProvider({ children }: { children: ReactNode }) {
setError(null);
try {
const [categoriesResult, accountsResult, banksResult] =
const [categoriesResult, accountsResult, banksResult, transactionsResult] =
await Promise.all([
categorySyncService.sync(),
accountSyncService.sync(),
bankSyncService.sync(),
transactionSyncService.sync(),
]);
const allErrors = [
...categoriesResult.errors,
...accountsResult.errors,
...banksResult.errors,
...transactionsResult.errors,
];
if (allErrors.length > 0) {

View File

@ -23,7 +23,7 @@ export async function resetDatabase(): Promise<void> {
}
export interface IndexedDBRecord {
id: number;
id: number | string;
user_id?: number | null;
created_at: string;
updated_at: string;
@ -170,7 +170,7 @@ class IndexedDBService {
async getById<T extends IndexedDBRecord>(
storeName: StoreName,
id: number,
id: number | string,
): Promise<T | null> {
const db = await this.init();
return new Promise((resolve, reject) => {
@ -188,6 +188,13 @@ class IndexedDBService {
});
}
async get<T extends IndexedDBRecord>(
storeName: StoreName,
id: number | string,
): Promise<T | null> {
return this.getById<T>(storeName, id);
}
async put<T extends IndexedDBRecord>(
storeName: StoreName,
record: T,
@ -255,7 +262,7 @@ class IndexedDBService {
});
}
async delete(storeName: StoreName, id: number): Promise<void> {
async delete(storeName: StoreName, id: number | string): Promise<void> {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite');

View File

@ -2,9 +2,10 @@ import { SyncManager } from '@/lib/sync-manager';
import { indexedDBService } from '@/lib/indexeddb';
import { encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { uuidv7 } from 'uuidv7';
export interface Transaction {
id: number;
id: string;
user_id: number;
account_id: number;
category_id: number | null;
@ -37,8 +38,8 @@ class TransactionSyncService {
return await this.syncManager.getAll<Transaction>();
}
async getById(id: number): Promise<Transaction | null> {
return await this.syncManager.getById<Transaction>(id);
async getById(id: string): Promise<Transaction | null> {
return await indexedDBService.get<Transaction>('transactions', id);
}
async getByAccountId(accountId: number): Promise<Transaction[]> {
@ -61,10 +62,9 @@ class TransactionSyncService {
const created: Transaction[] = [];
for (const data of transactions) {
const tempId = Date.now() + created.length;
const record = {
...data,
id: tempId,
id: uuidv7(),
created_at: timestamp,
updated_at: timestamp,
} as Transaction;
@ -86,12 +86,30 @@ class TransactionSyncService {
}
}
async update(id: number, data: Partial<Transaction>): Promise<void> {
await this.syncManager.updateLocal<Transaction>(id, data);
async update(id: string, data: Partial<Transaction>): Promise<void> {
const existing = await this.getById(id);
if (!existing) {
throw new Error('Transaction not found');
}
const updated = {
...existing,
...data,
updated_at: new Date().toISOString(),
};
await indexedDBService.put('transactions', updated);
await indexedDBService.addPendingChange('transactions', 'update', updated);
}
async delete(id: number): Promise<void> {
await this.syncManager.deleteLocal(id);
async delete(id: string): Promise<void> {
const transaction = await this.getById(id);
if (!transaction) {
throw new Error('Transaction not found');
}
await indexedDBService.delete('transactions', id);
await indexedDBService.addPendingChange('transactions', 'delete', transaction);
}
async isDuplicate(

View File

@ -26,6 +26,10 @@ Route::middleware(['auth'])->group(function () {
Route::get('api/sync/categories', [CategorySyncController::class, 'index']);
Route::get('api/sync/accounts', [AccountSyncController::class, 'index']);
Route::get('api/sync/banks', [BankSyncController::class, 'index']);
Route::get('api/sync/transactions', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'index']);
Route::post('api/sync/transactions', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'store']);
Route::patch('api/sync/transactions/{transaction}', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'update']);
Route::delete('api/sync/transactions/{transaction}', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'destroy']);
});
Route::middleware(['auth', 'verified', 'redirect.encryption'])->group(function () {

View File

@ -0,0 +1,237 @@
<?php
use App\Models\Account;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Str;
it('can fetch user transactions', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$transactions = Transaction::factory()->count(3)->for($user)->for($account)->create();
$response = $this->actingAs($user)->getJson('/api/sync/transactions');
$response->assertSuccessful()
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => [
'id',
'user_id',
'account_id',
'category_id',
'description',
'description_iv',
'transaction_date',
'amount',
'currency_code',
'created_at',
'updated_at',
],
],
]);
});
it('only returns user own transactions', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$account = Account::factory()->for($user)->create();
$otherAccount = Account::factory()->for($otherUser)->create();
Transaction::factory()->for($user)->for($account)->create();
Transaction::factory()->for($otherUser)->for($otherAccount)->create();
$response = $this->actingAs($user)->getJson('/api/sync/transactions');
$response->assertSuccessful()
->assertJsonCount(1, 'data');
});
it('can filter transactions by updated_at', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$oldTransaction = Transaction::factory()->for($user)->for($account)->create([
'updated_at' => now()->subDays(2),
]);
$newTransaction = Transaction::factory()->for($user)->for($account)->create([
'updated_at' => now(),
]);
$response = $this->actingAs($user)->getJson('/api/sync/transactions?since='.now()->subDay()->toISOString());
$response->assertSuccessful()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $newTransaction->id);
});
it('can create a transaction', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$category = Category::factory()->for($user)->create();
$transactionData = [
'account_id' => $account->id,
'category_id' => $category->id,
'description' => 'encrypted_description',
'description_iv' => '1234567890123456',
'transaction_date' => now()->toDateString(),
'amount' => '100.50',
'currency_code' => 'USD',
'notes' => null,
'notes_iv' => null,
];
$response = $this->actingAs($user)->postJson('/api/sync/transactions', $transactionData);
$response->assertCreated()
->assertJsonStructure([
'data' => [
'id',
'user_id',
'account_id',
'category_id',
'description',
'description_iv',
'transaction_date',
'amount',
'currency_code',
'created_at',
'updated_at',
],
]);
$this->assertDatabaseHas('transactions', [
'user_id' => $user->id,
'account_id' => $account->id,
'category_id' => $category->id,
'description' => 'encrypted_description',
'amount' => '100.50',
]);
});
it('can create a transaction with a UUID', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$uuid = (string) Str::uuid7();
$transactionData = [
'id' => $uuid,
'account_id' => $account->id,
'category_id' => null,
'description' => 'encrypted_description',
'description_iv' => '1234567890123456',
'transaction_date' => now()->toDateString(),
'amount' => '100.50',
'currency_code' => 'USD',
'notes' => null,
'notes_iv' => null,
];
$response = $this->actingAs($user)->postJson('/api/sync/transactions', $transactionData);
$response->assertCreated()
->assertJsonPath('data.id', $uuid);
$this->assertDatabaseHas('transactions', [
'id' => $uuid,
'user_id' => $user->id,
]);
});
it('validates required fields when creating a transaction', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/sync/transactions', []);
$response->assertUnprocessable()
->assertJsonValidationErrors(['account_id', 'description', 'description_iv', 'transaction_date', 'amount', 'currency_code']);
});
it('can update a transaction', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$transaction = Transaction::factory()->for($user)->for($account)->create([
'amount' => '100.00',
'description' => 'old_description',
]);
$updateData = [
'account_id' => $account->id,
'category_id' => null,
'description' => 'new_description',
'description_iv' => $transaction->description_iv,
'transaction_date' => $transaction->transaction_date->toDateString(),
'amount' => '200.00',
'currency_code' => $transaction->currency_code,
'notes' => null,
'notes_iv' => null,
];
$response = $this->actingAs($user)->patchJson("/api/sync/transactions/{$transaction->id}", $updateData);
$response->assertSuccessful()
->assertJsonPath('data.amount', '200.00')
->assertJsonPath('data.description', 'new_description');
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'amount' => '200.00',
'description' => 'new_description',
]);
});
it('cannot update another user transaction', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$account = Account::factory()->for($otherUser)->create();
$transaction = Transaction::factory()->for($otherUser)->for($account)->create();
$updateData = [
'account_id' => $account->id,
'category_id' => null,
'description' => 'hacked',
'description_iv' => $transaction->description_iv,
'transaction_date' => $transaction->transaction_date->toDateString(),
'amount' => '999.99',
'currency_code' => $transaction->currency_code,
'notes' => null,
'notes_iv' => null,
];
$response = $this->actingAs($user)->patchJson("/api/sync/transactions/{$transaction->id}", $updateData);
$response->assertForbidden();
});
it('can delete a transaction', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$transaction = Transaction::factory()->for($user)->for($account)->create();
$response = $this->actingAs($user)->deleteJson("/api/sync/transactions/{$transaction->id}");
$response->assertSuccessful();
$this->assertDatabaseMissing('transactions', [
'id' => $transaction->id,
]);
});
it('cannot delete another user transaction', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$account = Account::factory()->for($otherUser)->create();
$transaction = Transaction::factory()->for($otherUser)->for($account)->create();
$response = $this->actingAs($user)->deleteJson("/api/sync/transactions/{$transaction->id}");
$response->assertForbidden();
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
]);
});