(
'/api/encryption/message',
diff --git a/resources/js/hooks/use-decrypt-transactions.ts b/resources/js/hooks/use-decrypt-transactions.ts
new file mode 100644
index 00000000..db8f1b27
--- /dev/null
+++ b/resources/js/hooks/use-decrypt-transactions.ts
@@ -0,0 +1,111 @@
+import { useEncryptionKey } from '@/contexts/encryption-key-context';
+import { decrypt, importKey } from '@/lib/crypto';
+import { getStoredKey } from '@/lib/key-storage';
+import { SharedData } from '@/types';
+import { usePage } from '@inertiajs/react';
+import axios from 'axios';
+import { useEffect, useRef } from 'react';
+
+interface EncryptedTransaction {
+ id: string;
+ description: string;
+ description_iv: string | null;
+ notes: string | null;
+ notes_iv: string | null;
+}
+
+interface PaginatedResponse {
+ data: EncryptedTransaction[];
+ next_page_url: string | null;
+}
+
+interface BulkUpdateItem {
+ id: string;
+ description?: string;
+ notes?: string | null;
+ description_iv: null;
+ notes_iv: null;
+}
+
+export function useDecryptTransactions() {
+ const { isKeySet } = useEncryptionKey();
+ const { hasEncryptedTransactions } = usePage
().props;
+ const hasRun = useRef(false);
+
+ useEffect(() => {
+ if (!isKeySet || !hasEncryptedTransactions || hasRun.current) {
+ return;
+ }
+
+ hasRun.current = true;
+
+ async function migrateTransactions() {
+ try {
+ const keyString = getStoredKey();
+ if (!keyString) {
+ return;
+ }
+
+ const key = await importKey(keyString);
+
+ let url: string | null = '/api/transactions?encrypted=true';
+
+ while (url) {
+ const { data: page } =
+ await axios.get(url);
+
+ const batch: BulkUpdateItem[] = [];
+
+ for (const transaction of page.data) {
+ try {
+ const item: BulkUpdateItem = {
+ id: transaction.id,
+ description_iv: null,
+ notes_iv: null,
+ };
+
+ if (transaction.description_iv) {
+ item.description = await decrypt(
+ transaction.description,
+ key,
+ transaction.description_iv,
+ );
+ }
+
+ if (transaction.notes_iv && transaction.notes) {
+ item.notes = await decrypt(
+ transaction.notes,
+ key,
+ transaction.notes_iv,
+ );
+ }
+
+ batch.push(item);
+ } catch {
+ // Skip transactions that fail to decrypt
+ }
+ }
+
+ if (batch.length > 0) {
+ // Send in chunks of 50
+ for (let i = 0; i < batch.length; i += 50) {
+ const chunk = batch.slice(i, i + 50);
+ await axios.patch('/api/transactions/bulk', {
+ transactions: chunk,
+ });
+ }
+ }
+
+ url = page.next_page_url;
+ }
+
+ window.location.reload();
+ } catch {
+ // Silent failure — migration will retry next session
+ hasRun.current = false;
+ }
+ }
+
+ migrateTransactions();
+ }, [isKeySet, hasEncryptedTransactions]);
+}
diff --git a/resources/js/layouts/app/app-sidebar-layout.tsx b/resources/js/layouts/app/app-sidebar-layout.tsx
index 079e584b..5c8ec079 100644
--- a/resources/js/layouts/app/app-sidebar-layout.tsx
+++ b/resources/js/layouts/app/app-sidebar-layout.tsx
@@ -3,6 +3,7 @@ import { AppShell } from '@/components/app-shell';
import { AppSidebar } from '@/components/app-sidebar';
import { AppSidebarHeader } from '@/components/app-sidebar-header';
import { useDecryptAccountNames } from '@/hooks/use-decrypt-account-names';
+import { useDecryptTransactions } from '@/hooks/use-decrypt-transactions';
import { type BreadcrumbItem } from '@/types';
import { type PropsWithChildren } from 'react';
@@ -11,6 +12,7 @@ export default function AppSidebarLayout({
breadcrumbs = [],
}: PropsWithChildren<{ breadcrumbs?: BreadcrumbItem[] }>) {
useDecryptAccountNames();
+ useDecryptTransactions();
return (
diff --git a/resources/js/services/transaction-sync.ts b/resources/js/services/transaction-sync.ts
index c39565aa..a525831a 100644
--- a/resources/js/services/transaction-sync.ts
+++ b/resources/js/services/transaction-sync.ts
@@ -63,7 +63,7 @@ class TransactionSyncService {
data: Omit,
): Promise {
const response = await axios.post('/transactions', data);
- const serverData = response.data.data;
+ const serverData = response.data.data || response.data;
const label_ids = serverData.labels?.map((l: { id: string }) => l.id);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -100,7 +100,7 @@ class TransactionSyncService {
label_ids,
});
- const serverData = response.data.data;
+ const serverData = response.data.data || response.data;
const serverLabelIds = serverData.labels?.map(
(l: { id: string }) => l.id,
@@ -214,24 +214,24 @@ class TransactionSyncService {
});
const keyString = getStoredKey();
- if (!keyString) {
- console.warn('No encryption key found for duplicate check');
- return transactions.map(() => false);
- }
-
- const key = await importKey(keyString);
- const { decrypt } = await import('@/lib/crypto');
+ const key = keyString ? await importKey(keyString) : null;
const decryptedTransactions = await Promise.all(
transactionsInRange.map(async (t) => {
try {
- const decryptedDescription = t.description_iv
- ? await decrypt(
- t.description,
- key,
- t.description_iv,
- )
- : t.description;
+ let decryptedDescription: string;
+ if (t.description_iv && key) {
+ const { decrypt } = await import('@/lib/crypto');
+ decryptedDescription = await decrypt(
+ t.description,
+ key,
+ t.description_iv,
+ );
+ } else if (t.description_iv && !key) {
+ return null;
+ } else {
+ decryptedDescription = t.description;
+ }
return {
transaction_date: normalizeDate(t.transaction_date),
amount: parseFloat(t.amount),
diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts
index 9c9ff912..6fe36e92 100644
--- a/resources/js/types/index.d.ts
+++ b/resources/js/types/index.d.ts
@@ -61,6 +61,7 @@ export interface SharedData {
sidebarOpen: boolean;
features: Features;
hasEncryptedAccounts: boolean;
+ hasEncryptedTransactions: boolean;
hasEncryptionSetup: boolean;
locale: string;
translations: Record;
diff --git a/routes/api.php b/routes/api.php
index 1fc93d81..ab2d5ee2 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -5,6 +5,7 @@ use App\Http\Controllers\Api\AccountController;
use App\Http\Controllers\Api\CashflowAnalyticsController;
use App\Http\Controllers\Api\DashboardAnalyticsController;
use App\Http\Controllers\Api\ImportDataController;
+use App\Http\Controllers\Api\TransactionController;
use App\Http\Controllers\EncryptionController;
use App\Http\Controllers\Sync\TransactionSyncController;
use Illuminate\Support\Facades\Route;
@@ -22,6 +23,10 @@ Route::middleware(['web', 'auth'])->group(function () {
Route::get('transactions', [TransactionSyncController::class, 'index']);
});
+ // Transactions
+ Route::get('transactions', [TransactionController::class, 'index'])->name('api.transactions.index');
+ Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('api.transactions.bulk-update');
+
// Accounts
Route::get('accounts', [AccountController::class, 'index'])->name('api.accounts.index');
Route::put('accounts/{account}', [AccountController::class, 'update'])->name('api.accounts.update');
diff --git a/routes/web.php b/routes/web.php
index 4c00cd30..1c7a07fd 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -47,6 +47,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('onboarding', [OnboardingController::class, 'index'])->name('onboarding');
Route::post('onboarding/complete', [OnboardingController::class, 'complete'])->name('onboarding.complete');
});
+
+ // Accessible during onboarding for transaction import
+ Route::post('transactions', [TransactionController::class, 'store'])->name('transactions.store');
});
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
@@ -58,7 +61,6 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(functi
Route::get('transactions', [TransactionController::class, 'index'])->name('transactions.index');
Route::get('transactions/categorize', [TransactionController::class, 'categorize'])->name('transactions.categorize');
- Route::post('transactions', [TransactionController::class, 'store'])->name('transactions.store');
Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('transactions.bulk-update');
Route::patch('transactions/{transaction}', [TransactionController::class, 'update'])->name('transactions.update');
Route::delete('transactions/{transaction}', [TransactionController::class, 'destroy'])->name('transactions.destroy');
diff --git a/tests/Browser/OnboardingFlowTest.php b/tests/Browser/OnboardingFlowTest.php
index 5ca84ed6..88efa3c6 100644
--- a/tests/Browser/OnboardingFlowTest.php
+++ b/tests/Browser/OnboardingFlowTest.php
@@ -88,21 +88,6 @@ it('navigates from welcome to account types', function () {
->assertNoJavascriptErrors();
});
-it('marks user as onboarded when completing onboarding', function () {
- $user = User::factory()->create([
- 'onboarded_at' => null,
- 'encryption_salt' => 'test-salt',
- ]);
-
- expect($user->isOnboarded())->toBeFalse();
-
- $this->actingAs($user)->post('/onboarding/complete');
-
- $user->refresh();
- expect($user->isOnboarded())->toBeTrue();
- expect($user->onboarded_at)->not->toBeNull();
-});
-
// =============================================================================
// Existing Account Flow Tests
// =============================================================================
@@ -236,8 +221,10 @@ it('shows add another account form without first account restriction', function
// Full End-to-End Flow Test
// =============================================================================
-it('completes onboarding flow through account creation', function () {
- // Create a bank for the account creation step
+it('completes entire onboarding flow with account creation, transaction import, and ends on subscribe page', function () {
+ // Enable subscriptions so user ends on /subscribe after completing onboarding
+ config(['subscriptions.enabled' => true]);
+
Bank::factory()->create(['name' => 'Chase Bank']);
$user = User::factory()->create([
@@ -259,59 +246,93 @@ it('completes onboarding flow through account creation', function () {
// Step 2: Account Types
$page->assertSee('Account Types')
- ->assertSee('Checking')
- ->assertSee('Savings')
- ->assertSee('Credit Card')
->click('Create Your First Account')
->wait(1);
- // Step 3: Create Account
+ // Step 3: Create Account (checking with EUR currency)
$page->assertSee('Create an Account')
->assertSee('Your first account must be a')
->fill('#display_name', 'My Checking Account')
- // Select bank from combobox - need to search first
->click('Select bank...')
->wait(1)
->fill('[placeholder="Search bank..."]', 'Chase')
->wait(2)
->click('Chase Bank')
->wait(1)
- // Select currency - click on the dropdown item (Radix UI creates role="option")
->click('Select currency')
->wait(1)
- ->click('[role="option"]:has-text("USD")')
+ ->click('[role="option"]:has-text("EUR")')
->wait(1)
->click('Create Account')
+ ->wait(5);
+
+ // Step 4: Import Transactions - open the import drawer
+ $page->assertSee('Import Your Transactions')
+ ->click('Import Transactions')
->wait(3);
- // Step 4: Import Transactions step should appear
- $page->assertSee('Import Your Transactions')
+ // The drawer auto-selects the only account and moves to Upload File step
+ // Upload the test CSV file
+ $csvPath = __DIR__.'/assets/test-transactions.csv';
+ $page->attach('input[type="file"]', $csvPath)
+ ->wait(2)
+ ->click('Next')
+ ->wait(2);
+
+ // Column Mapping step (auto-detected: Date, Description, Amount)
+ $page->assertSee('Map Columns')
+ ->click('Preview Transactions')
+ ->wait(3);
+
+ // Preview step - import all 5 transactions from the CSV
+ $page->assertSee('Preview Transactions')
+ ->click('Import 5 Transactions')
+ ->wait(15);
+
+ // After import completes, drawer closes and transitions to Category Types
+ $page->assertSee('Understanding Categories')
+ ->click('Continue')
+ ->wait(1);
+
+ // Smart Rules
+ $page->assertSee('Smart Automation Rules')
+ ->click('Continue to Import')
+ ->wait(1);
+
+ // More Accounts - verify account is listed, then finish
+ $page->assertSee('Great Progress!')
+ ->assertSee('My Checking Account')
+ ->click('Finish Setup')
+ ->wait(1);
+
+ // Complete step
+ $page->assertSee("You're All Set!")
+ ->click('Go to Dashboard')
+ ->wait(5);
+
+ // Since SUBSCRIPTIONS_ENABLED is true, user should end on /subscribe
+ $page->assertPathIs('/subscribe')
->assertNoJavascriptErrors();
- // Verify account was created
- expect($user->accounts()->count())->toBe(1);
- expect($user->accounts()->first()->type->value)->toBe('checking');
-});
-
-it('marks user as onboarded when completing via API', function () {
- $user = User::factory()->create([
- 'onboarded_at' => null,
- 'encryption_salt' => 'test-salt',
- ]);
-
- $bank = Bank::factory()->create();
- Account::factory()->create([
- 'user_id' => $user->id,
- 'bank_id' => $bank->id,
- 'type' => 'checking',
- ]);
-
- expect($user->isOnboarded())->toBeFalse();
-
- // Complete onboarding via POST
- $this->actingAs($user)->post('/onboarding/complete');
-
+ // === Database Assertions ===
$user->refresh();
+
+ // User should be marked as onboarded
expect($user->isOnboarded())->toBeTrue();
expect($user->onboarded_at)->not->toBeNull();
+
+ // User currency_code should match the first account's currency
+ expect($user->currency_code)->toBe('EUR');
+
+ // Account should exist with correct properties
+ $account = $user->accounts()->first();
+ expect($account)->not->toBeNull();
+ expect($account->type->value)->toBe('checking');
+ expect($account->currency_code)->toBe('EUR');
+ expect($account->name)->toBe('My Checking Account');
+
+ // Transactions should be imported in the correct account
+ $transactions = $user->transactions()->where('account_id', $account->id)->get();
+ expect($transactions)->toHaveCount(5);
+ expect($transactions->pluck('currency_code')->unique()->first())->toBe('EUR');
});
diff --git a/tests/Feature/DecryptTransactionsTest.php b/tests/Feature/DecryptTransactionsTest.php
new file mode 100644
index 00000000..a4045f8f
--- /dev/null
+++ b/tests/Feature/DecryptTransactionsTest.php
@@ -0,0 +1,235 @@
+user = User::factory()->onboarded()->create();
+ $this->actingAs($this->user);
+});
+
+test('encrypted transactions endpoint returns only encrypted transactions', function () {
+ $encrypted = Transaction::factory()->create([
+ 'user_id' => $this->user->id,
+ 'description_iv' => 'some-iv',
+ ]);
+ Transaction::factory()->plaintext()->create([
+ 'user_id' => $this->user->id,
+ ]);
+
+ $response = $this->getJson('/api/transactions?encrypted=true');
+
+ $response->assertOk();
+ $data = $response->json('data');
+ expect($data)->toHaveCount(1);
+ expect($data[0]['id'])->toBe($encrypted->id);
+});
+
+test('plaintext filter returns only plaintext transactions', function () {
+ Transaction::factory()->create([
+ 'user_id' => $this->user->id,
+ 'description_iv' => 'some-iv',
+ ]);
+ $plaintext = Transaction::factory()->plaintext()->create([
+ 'user_id' => $this->user->id,
+ ]);
+
+ $response = $this->getJson('/api/transactions?encrypted=false');
+
+ $response->assertOk();
+ $data = $response->json('data');
+ expect($data)->toHaveCount(1);
+ expect($data[0]['id'])->toBe($plaintext->id);
+});
+
+test('encrypted transactions endpoint paginates correctly', function () {
+ $account = Account::factory()->create(['user_id' => $this->user->id]);
+ $category = Category::factory()->create(['user_id' => $this->user->id]);
+
+ Transaction::factory()->count(150)->create([
+ 'user_id' => $this->user->id,
+ 'account_id' => $account->id,
+ 'category_id' => $category->id,
+ 'description_iv' => 'some-iv',
+ ]);
+
+ $response = $this->getJson('/api/transactions?encrypted=true');
+
+ $response->assertOk();
+ expect($response->json('data'))->toHaveCount(100);
+ expect($response->json('next_page_url'))->not->toBeNull();
+
+ $response2 = $this->getJson('/api/transactions?encrypted=true&page=2');
+ expect($response2->json('data'))->toHaveCount(50);
+ expect($response2->json('next_page_url'))->toBeNull();
+});
+
+test('encrypted transactions endpoint is scoped to authenticated user', function () {
+ $otherUser = User::factory()->create();
+
+ Transaction::factory()->create([
+ 'user_id' => $otherUser->id,
+ 'description_iv' => 'some-iv',
+ ]);
+ $myTransaction = Transaction::factory()->create([
+ 'user_id' => $this->user->id,
+ 'description_iv' => 'some-iv',
+ ]);
+
+ $response = $this->getJson('/api/transactions?encrypted=true');
+
+ $response->assertOk();
+ $data = $response->json('data');
+ expect($data)->toHaveCount(1);
+ expect($data[0]['id'])->toBe($myTransaction->id);
+});
+
+test('bulk update clears IVs and sets plaintext values', function () {
+ $transaction = Transaction::factory()->create([
+ 'user_id' => $this->user->id,
+ 'description' => 'encrypted-desc',
+ 'description_iv' => 'some-iv',
+ 'notes' => 'encrypted-notes',
+ 'notes_iv' => 'some-notes-iv',
+ ]);
+
+ $response = $this->patchJson('/api/transactions/bulk', [
+ 'transactions' => [
+ [
+ 'id' => $transaction->id,
+ 'description' => 'Decrypted description',
+ 'notes' => 'Decrypted notes',
+ 'description_iv' => null,
+ 'notes_iv' => null,
+ ],
+ ],
+ ]);
+
+ $response->assertOk();
+
+ $transaction->refresh();
+ expect($transaction->description)->toBe('Decrypted description');
+ expect($transaction->notes)->toBe('Decrypted notes');
+ expect($transaction->description_iv)->toBeNull();
+ expect($transaction->notes_iv)->toBeNull();
+});
+
+test('bulk update rejects other user transactions', function () {
+ $otherUser = User::factory()->create();
+ $transaction = Transaction::factory()->create([
+ 'user_id' => $otherUser->id,
+ 'description_iv' => 'some-iv',
+ ]);
+
+ $response = $this->patchJson('/api/transactions/bulk', [
+ 'transactions' => [
+ [
+ 'id' => $transaction->id,
+ 'description' => 'Stolen data',
+ 'description_iv' => null,
+ 'notes_iv' => null,
+ ],
+ ],
+ ]);
+
+ $response->assertForbidden();
+});
+
+test('bulk update validates max batch size', function () {
+ $account = Account::factory()->create(['user_id' => $this->user->id]);
+ $category = Category::factory()->create(['user_id' => $this->user->id]);
+
+ $transactions = Transaction::factory()->count(51)->create([
+ 'user_id' => $this->user->id,
+ 'account_id' => $account->id,
+ 'category_id' => $category->id,
+ 'description_iv' => 'some-iv',
+ ]);
+
+ $payload = $transactions->map(fn ($t) => [
+ 'id' => $t->id,
+ 'description' => 'decrypted',
+ 'description_iv' => null,
+ 'notes_iv' => null,
+ ])->toArray();
+
+ $response = $this->patchJson('/api/transactions/bulk', [
+ 'transactions' => $payload,
+ ]);
+
+ $response->assertUnprocessable();
+ $response->assertJsonValidationErrors('transactions');
+});
+
+test('bulk update validates required fields', function () {
+ $response = $this->patchJson('/api/transactions/bulk', [
+ 'transactions' => [
+ ['description' => 'missing id'],
+ ],
+ ]);
+
+ $response->assertUnprocessable();
+ $response->assertJsonValidationErrors('transactions.0.id');
+});
+
+test('bulk update handles nullable notes correctly', function () {
+ $transaction = Transaction::factory()->create([
+ 'user_id' => $this->user->id,
+ 'description' => 'encrypted-desc',
+ 'description_iv' => 'some-iv',
+ 'notes' => null,
+ 'notes_iv' => null,
+ ]);
+
+ $response = $this->patchJson('/api/transactions/bulk', [
+ 'transactions' => [
+ [
+ 'id' => $transaction->id,
+ 'description' => 'Decrypted description',
+ 'notes' => null,
+ 'description_iv' => null,
+ 'notes_iv' => null,
+ ],
+ ],
+ ]);
+
+ $response->assertOk();
+
+ $transaction->refresh();
+ expect($transaction->description)->toBe('Decrypted description');
+ expect($transaction->notes)->toBeNull();
+ expect($transaction->description_iv)->toBeNull();
+ expect($transaction->notes_iv)->toBeNull();
+});
+
+test('guests cannot access transactions API', function () {
+ auth()->logout();
+
+ $this->getJson('/api/transactions')->assertUnauthorized();
+ $this->patchJson('/api/transactions/bulk', ['transactions' => []])->assertUnauthorized();
+});
+
+test('bulk update does not fire model events', function () {
+ $transaction = Transaction::factory()->create([
+ 'user_id' => $this->user->id,
+ 'description_iv' => 'some-iv',
+ ]);
+
+ $originalUpdatedAt = $transaction->updated_at;
+
+ $this->patchJson('/api/transactions/bulk', [
+ 'transactions' => [
+ [
+ 'id' => $transaction->id,
+ 'description' => 'Decrypted',
+ 'description_iv' => null,
+ 'notes_iv' => null,
+ ],
+ ],
+ ])->assertOk();
+
+ $transaction->refresh();
+ expect($transaction->updated_at->toDateTimeString())->toBe($originalUpdatedAt->toDateTimeString());
+});