diff --git a/app/Console/Commands/Concerns/ResolvesFeatures.php b/app/Console/Commands/Concerns/ResolvesFeatures.php index ad21d0f6..395a0f0f 100644 --- a/app/Console/Commands/Concerns/ResolvesFeatures.php +++ b/app/Console/Commands/Concerns/ResolvesFeatures.php @@ -49,6 +49,6 @@ trait ResolvesFeatures private function getStringBasedFeatures(): array { - return ['budgets']; + return ['budgets', 'plaintext-transactions']; } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 2c933472..2aab5a0c 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -70,6 +70,7 @@ class HandleInertiaRequests extends Middleware 'features' => [ 'cashflow' => true, 'budgets' => $user ? Feature::for($user)->active('budgets') : false, + 'plaintext-transactions' => $user ? Feature::for($user)->active('plaintext-transactions') : false, ], 'accounts' => fn () => $user ? $user->accounts() ->with('bank:id,name,logo') diff --git a/app/Http/Requests/StoreTransactionRequest.php b/app/Http/Requests/StoreTransactionRequest.php index d5d2cd65..58d82bbe 100644 --- a/app/Http/Requests/StoreTransactionRequest.php +++ b/app/Http/Requests/StoreTransactionRequest.php @@ -5,6 +5,7 @@ namespace App\Http\Requests; use App\Enums\TransactionSource; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Laravel\Pennant\Feature; class StoreTransactionRequest extends FormRequest { @@ -30,7 +31,9 @@ class StoreTransactionRequest extends FormRequest }), ], 'description' => ['required', 'string'], - 'description_iv' => ['required', 'string', 'size:16'], + 'description_iv' => Feature::for($this->user())->active('plaintext-transactions') + ? ['nullable', 'string', 'size:16'] + : ['required', 'string', 'size:16'], 'transaction_date' => ['required', 'date'], 'amount' => ['required', 'integer'], 'currency_code' => ['required', 'string', 'size:3'], diff --git a/app/Http/Requests/UpdateTransactionRequest.php b/app/Http/Requests/UpdateTransactionRequest.php index 2ded7f41..e08b9e08 100644 --- a/app/Http/Requests/UpdateTransactionRequest.php +++ b/app/Http/Requests/UpdateTransactionRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Laravel\Pennant\Feature; class UpdateTransactionRequest extends FormRequest { @@ -22,7 +23,9 @@ class UpdateTransactionRequest extends FormRequest }), ], 'description' => ['sometimes', 'string'], - 'description_iv' => ['sometimes', 'string', 'size:16'], + 'description_iv' => Feature::for($this->user())->active('plaintext-transactions') + ? ['nullable', 'string', 'size:16'] + : ['sometimes', 'string', 'size:16'], 'notes' => ['nullable', 'string'], 'notes_iv' => ['nullable', 'string', 'size:16'], 'label_ids' => ['nullable', 'array'], diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 797fd6e7..a3f7f2fc 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -40,5 +40,6 @@ class AppServiceProvider extends ServiceProvider }); Feature::define('budgets', fn (User $user) => true); + Feature::define('plaintext-transactions', fn (User $user) => false); } } diff --git a/database/factories/TransactionFactory.php b/database/factories/TransactionFactory.php index edbf05f9..e3683359 100644 --- a/database/factories/TransactionFactory.php +++ b/database/factories/TransactionFactory.php @@ -41,4 +41,14 @@ class TransactionFactory extends Factory 'source' => TransactionSource::Imported, ]); } + + public function plaintext(): static + { + return $this->state(fn (array $attributes) => [ + 'description' => fake()->sentence(), + 'description_iv' => null, + 'notes' => fake()->optional()->paragraph(), + 'notes_iv' => null, + ]); + } } diff --git a/database/migrations/2026_02_09_131403_make_description_iv_nullable_on_transactions.php b/database/migrations/2026_02_09_131403_make_description_iv_nullable_on_transactions.php new file mode 100644 index 00000000..b8d58c24 --- /dev/null +++ b/database/migrations/2026_02_09_131403_make_description_iv_nullable_on_transactions.php @@ -0,0 +1,28 @@ +string('description_iv', 16)->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->string('description_iv', 16)->nullable(false)->change(); + }); + } +}; diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index c694197e..aa67b102 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -41,9 +41,11 @@ import { import { type AutomationRule } from '@/types/automation-rule'; import { type Category } from '@/types/category'; import { type Label } from '@/types/label'; +import { type SharedData } from '@/types'; import { type DecryptedTransaction } from '@/types/transaction'; import { formatDate } from '@/utils/date'; import { __ } from '@/utils/i18n'; +import { usePage } from '@inertiajs/react'; import { getYear, parseISO } from 'date-fns'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; @@ -79,6 +81,8 @@ export function EditTransactionDialog({ const { isKeySet } = useEncryptionKey(); const { sync } = useSyncContext(); + const { features } = usePage().props; + const isPlaintext = features['plaintext-transactions']; const [transactionDate, setTransactionDate] = useState(''); const [description, setDescription] = useState(''); const [amount, setAmount] = useState(0); @@ -332,7 +336,7 @@ export function EditTransactionDialog({ async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if (!isKeySet) { + if (!isPlaintext && !isKeySet) { toast.error( __('Please unlock your encryption key to save transactions'), ); @@ -370,10 +374,10 @@ export function EditTransactionDialog({ try { const trimmedDescription = description.trim(); const keyString = getStoredKey(); - if (!keyString) { + if (!isPlaintext && !keyString) { throw new Error(__('Encryption key not available')); } - const key = await importKey(keyString); + const key = keyString ? await importKey(keyString) : null; if (mode === 'create') { const ruleResult = await checkAndApplyAutomationRules(); @@ -395,19 +399,30 @@ export function EditTransactionDialog({ finalLabelIds = [...ruleResult.labelIds]; } + let finalDescription: string; + let finalDescriptionIv: string | null; let encryptedNotes: string | null = null; let notesIv: string | null = null; - if (finalNotes) { - const encrypted = await encrypt(finalNotes, key); - encryptedNotes = encrypted.encrypted; - notesIv = encrypted.iv; - } + if (isPlaintext) { + finalDescription = trimmedDescription; + finalDescriptionIv = null; + encryptedNotes = finalNotes || null; + notesIv = null; + } else { + const encryptedDescription = await encrypt( + trimmedDescription, + key!, + ); + finalDescription = encryptedDescription.encrypted; + finalDescriptionIv = encryptedDescription.iv; - const encryptedDescription = await encrypt( - trimmedDescription, - key, - ); + if (finalNotes) { + const encrypted = await encrypt(finalNotes, key!); + encryptedNotes = encrypted.encrypted; + notesIv = encrypted.iv; + } + } const selectedAccount = accounts.find( (acc) => acc.id === accountId, @@ -420,8 +435,8 @@ export function EditTransactionDialog({ user_id: '00000000-0000-0000-0000-000000000000', account_id: accountId, category_id: finalCategoryId, - description: encryptedDescription.encrypted, - description_iv: encryptedDescription.iv, + description: finalDescription, + description_iv: finalDescriptionIv, transaction_date: transactionDate, amount: amount, currency_code: selectedAccount.currency_code, @@ -490,8 +505,11 @@ export function EditTransactionDialog({ let encryptedNotes: string | null = null; let notesIv: string | null = null; - if (trimmedNotes) { - const encrypted = await encrypt(trimmedNotes, key); + if (isPlaintext) { + encryptedNotes = trimmedNotes || null; + notesIv = null; + } else if (trimmedNotes) { + const encrypted = await encrypt(trimmedNotes, key!); encryptedNotes = encrypted.encrypted; notesIv = encrypted.iv; } @@ -501,7 +519,7 @@ export function EditTransactionDialog({ notes: string | null; notes_iv: string | null; description?: string; - description_iv?: string; + description_iv?: string | null; label_ids?: string[]; } = { category_id: selectedCategoryId, @@ -517,12 +535,17 @@ export function EditTransactionDialog({ transaction.source === 'manually_created' && trimmedDescription ) { - const encryptedDescription = await encrypt( - trimmedDescription, - key, - ); - updateData.description = encryptedDescription.encrypted; - updateData.description_iv = encryptedDescription.iv; + if (isPlaintext) { + updateData.description = trimmedDescription; + updateData.description_iv = null; + } else { + const encryptedDescription = await encrypt( + trimmedDescription, + key!, + ); + updateData.description = encryptedDescription.encrypted; + updateData.description_iv = encryptedDescription.iv; + } finalDecryptedDescription = trimmedDescription; } diff --git a/resources/js/components/transactions/encrypted-transaction-description.tsx b/resources/js/components/transactions/encrypted-transaction-description.tsx index 22608406..f8727f1b 100644 --- a/resources/js/components/transactions/encrypted-transaction-description.tsx +++ b/resources/js/components/transactions/encrypted-transaction-description.tsx @@ -39,7 +39,7 @@ function getFakeDescription(seed: string): string { interface EncryptedTransactionDescriptionProps { encryptedText: string; - iv: string; + iv: string | null; className?: string; length?: number | { min: number; max: number } | null; } @@ -58,6 +58,13 @@ export function EncryptedTransactionDescription({ [encryptedText], ); + if (!iv) { + if (isPrivacyModeEnabled) { + return {fakeDescription}; + } + return {encryptedText}; + } + if (isPrivacyModeEnabled && isKeySet) { return {fakeDescription}; } diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx index 00e3e212..be6135b2 100644 --- a/resources/js/components/transactions/import-transactions-drawer.tsx +++ b/resources/js/components/transactions/import-transactions-drawer.tsx @@ -31,8 +31,9 @@ import { type ColumnMapping, type ImportState, } from '@/types/import'; +import { type SharedData } from '@/types'; import { __ } from '@/utils/i18n'; -import { router } from '@inertiajs/react'; +import { router, usePage } from '@inertiajs/react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { ImportStepAccount } from './import-step-account'; @@ -70,6 +71,8 @@ export function ImportTransactionsDrawer({ onImportComplete, }: ImportTransactionsDrawerProps) { const { isKeySet } = useEncryptionKey(); + const { features } = usePage().props; + const isPlaintext = features['plaintext-transactions']; const [isImporting, setIsImporting] = useState(false); const [importProgress, setImportProgress] = useState(0); const [importTotal, setImportTotal] = useState(0); @@ -308,7 +311,7 @@ export function ImportTransactionsDrawer({ }; const handleConfirmImport = async () => { - if (!isKeySet) { + if (!isPlaintext && !isKeySet) { setError('Please unlock your encryption key first'); return; } @@ -345,10 +348,20 @@ export function ImportTransactionsDrawer({ batch.map(async (transaction, batchIndex) => { const rowNumber = i + batchIndex + 1; - const { encrypted, iv } = - await transactionSyncService.encryptDescription( - transaction.description, - ); + let encrypted: string; + let iv: string | null; + + if (isPlaintext) { + encrypted = transaction.description; + iv = null; + } else { + const result = + await transactionSyncService.encryptDescription( + transaction.description, + ); + encrypted = result.encrypted; + iv = result.iv; + } let categoryId: string | null = null; let notes: string | null = null; @@ -375,8 +388,20 @@ export function ImportTransactionsDrawer({ categoryId = ruleMatch.categoryId; } if (ruleMatch.note && ruleMatch.noteIv) { - notes = ruleMatch.note; - notesIv = ruleMatch.noteIv; + if (isPlaintext) { + const { decrypt } = await import( + '@/lib/crypto' + ); + notes = await decrypt( + ruleMatch.note, + key, + ruleMatch.noteIv, + ); + notesIv = null; + } else { + notes = ruleMatch.note; + notesIv = ruleMatch.noteIv; + } } if ( ruleMatch.labelIds && diff --git a/resources/js/components/transactions/transaction-list.tsx b/resources/js/components/transactions/transaction-list.tsx index e914368c..ff12523a 100644 --- a/resources/js/components/transactions/transaction-list.tsx +++ b/resources/js/components/transactions/transaction-list.tsx @@ -1,5 +1,7 @@ import { useLocale } from '@/hooks/use-locale'; +import { type SharedData } from '@/types'; import { __ } from '@/utils/i18n'; +import { usePage } from '@inertiajs/react'; import { Cell, ColumnFiltersState, @@ -247,6 +249,8 @@ export function TransactionList({ }: TransactionListProps) { const { isKeySet } = useEncryptionKey(); const locale = useLocale(); + const { features } = usePage().props; + const isPlaintext = features['plaintext-transactions']; const labels = initialLabels; @@ -342,7 +346,12 @@ export function TransactionList({ let decryptedDescription = ''; let decryptedNotes: string | null = null; - if (key) { + if (!transaction.description_iv) { + decryptedDescription = + transaction.description; + decryptedNotes = + transaction.notes || null; + } else if (key) { try { decryptedDescription = await decrypt( transaction.description, @@ -458,7 +467,10 @@ export function TransactionList({ let decryptedDescription = ''; let decryptedNotes: string | null = null; - if (key) { + if (!transaction.description_iv) { + decryptedDescription = transaction.description; + decryptedNotes = transaction.notes || null; + } else if (key) { try { decryptedDescription = await decrypt( transaction.description, @@ -581,7 +593,10 @@ export function TransactionList({ let decryptedDescription = ''; let decryptedNotes: string | null = null; - if (key) { + if (!transaction.description_iv) { + decryptedDescription = transaction.description; + decryptedNotes = transaction.notes || null; + } else if (key) { try { decryptedDescription = await decrypt( transaction.description, @@ -668,18 +683,23 @@ export function TransactionList({ let decryptedNotes: string | null = null; try { - decryptedDescription = await decrypt( - tx.description, - key, - tx.description_iv, - ); - - if (tx.notes && tx.notes_iv) { - decryptedNotes = await decrypt( - tx.notes, + if (!tx.description_iv) { + decryptedDescription = tx.description; + decryptedNotes = tx.notes || null; + } else { + decryptedDescription = await decrypt( + tx.description, key, - tx.notes_iv, + tx.description_iv, ); + + if (tx.notes && tx.notes_iv) { + decryptedNotes = await decrypt( + tx.notes, + key, + tx.notes_iv, + ); + } } } catch { continue; @@ -883,9 +903,17 @@ export function TransactionList({ ); if (combinedNote !== transaction.decryptedNotes) { - const encrypted = await encrypt(combinedNote, key); - finalNotes = encrypted.encrypted; - finalNotesIv = encrypted.iv; + if (isPlaintext) { + finalNotes = combinedNote; + finalNotesIv = null; + } else { + const encrypted = await encrypt( + combinedNote, + key, + ); + finalNotes = encrypted.encrypted; + finalNotesIv = encrypted.iv; + } consoleDebug('Combined notes with rule note'); } else { consoleDebug('Rule note already present, skipping'); @@ -911,7 +939,9 @@ export function TransactionList({ : null; let decryptedNotes = transaction.decryptedNotes; - if (finalNotes && finalNotesIv) { + if (finalNotes && !finalNotesIv) { + decryptedNotes = finalNotes; + } else if (finalNotes && finalNotesIv) { decryptedNotes = await decrypt( finalNotes, key, @@ -1039,9 +1069,17 @@ export function TransactionList({ ); if (combinedNote !== transaction.decryptedNotes) { - const encrypted = await encrypt(combinedNote, key); - finalNotes = encrypted.encrypted; - finalNotesIv = encrypted.iv; + if (isPlaintext) { + finalNotes = combinedNote; + finalNotesIv = null; + } else { + const encrypted = await encrypt( + combinedNote, + key, + ); + finalNotes = encrypted.encrypted; + finalNotesIv = encrypted.iv; + } consoleDebug('Combined notes with rule note'); } else { consoleDebug('Rule note already present, skipping'); @@ -1067,7 +1105,9 @@ export function TransactionList({ : null; let decryptedNotes = transaction.decryptedNotes; - if (finalNotes && finalNotesIv) { + if (finalNotes && !finalNotesIv) { + decryptedNotes = finalNotes; + } else if (finalNotes && finalNotesIv) { decryptedNotes = await decrypt( finalNotes, key, diff --git a/resources/js/hooks/use-re-evaluate-all-transactions.tsx b/resources/js/hooks/use-re-evaluate-all-transactions.tsx index 0cd4df40..eae3c76e 100644 --- a/resources/js/hooks/use-re-evaluate-all-transactions.tsx +++ b/resources/js/hooks/use-re-evaluate-all-transactions.tsx @@ -3,10 +3,12 @@ import { getStoredKey } from '@/lib/key-storage'; import { evaluateRules } from '@/lib/rule-engine'; import { appendNoteIfNotPresent } from '@/lib/utils'; import { transactionSyncService } from '@/services/transaction-sync'; +import type { SharedData } from '@/types'; 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 { usePage } from '@inertiajs/react'; import { useCallback } from 'react'; import { toast } from 'sonner'; @@ -20,6 +22,9 @@ interface ReEvaluateAllOptions { } export function useReEvaluateAllTransactions() { + const { features } = usePage().props; + const isPlaintext = features['plaintext-transactions']; + const reEvaluateAll = useCallback( async ( transactions: DecryptedTransaction[], @@ -90,12 +95,17 @@ export function useReEvaluateAllTransactions() { ); if (combinedNote !== transaction.decryptedNotes) { - const encrypted = await encrypt( - combinedNote, - key, - ); - finalNotes = encrypted.encrypted; - finalNotesIv = encrypted.iv; + if (isPlaintext) { + finalNotes = combinedNote; + finalNotesIv = null; + } else { + const encrypted = await encrypt( + combinedNote, + key, + ); + finalNotes = encrypted.encrypted; + finalNotesIv = encrypted.iv; + } } } diff --git a/resources/js/pages/transactions/categorize.tsx b/resources/js/pages/transactions/categorize.tsx index ddadf9c4..82701dea 100644 --- a/resources/js/pages/transactions/categorize.tsx +++ b/resources/js/pages/transactions/categorize.tsx @@ -195,7 +195,10 @@ export default function CategorizeTransactions({ let decryptedDescription = ''; let decryptedNotes: string | null = null; - if (key) { + if (!transaction.description_iv) { + decryptedDescription = transaction.description; + decryptedNotes = transaction.notes || null; + } else if (key) { try { decryptedDescription = await decrypt( transaction.description, diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 0a234da8..fb5756d1 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -1,6 +1,6 @@ import { useLocale } from '@/hooks/use-locale'; import { __ } from '@/utils/i18n'; -import { Head, router } from '@inertiajs/react'; +import { Head, router, usePage } from '@inertiajs/react'; import { Cell, ColumnFiltersState, @@ -60,7 +60,7 @@ import { getStoredKey } from '@/lib/key-storage'; import { evaluateRules } from '@/lib/rule-engine'; import { appendNoteIfNotPresent, cn } from '@/lib/utils'; import { transactionSyncService } from '@/services/transaction-sync'; -import { type BreadcrumbItem } from '@/types'; +import { type BreadcrumbItem, type SharedData } from '@/types'; import { type Account, type Bank } from '@/types/account'; import { type AutomationRule } from '@/types/automation-rule'; import { type Category } from '@/types/category'; @@ -367,6 +367,8 @@ export default function Transactions({ const { isKeySet } = useEncryptionKey(); const { sync } = useSyncContext(); const locale = useLocale(); + const { features } = usePage().props; + const isPlaintext = features['plaintext-transactions']; const [transactions, setTransactions] = useState( [], @@ -487,7 +489,10 @@ export default function Transactions({ let decryptedDescription = ''; let decryptedNotes: string | null = null; - if (key) { + if (!transaction.description_iv) { + decryptedDescription = transaction.description; + decryptedNotes = transaction.notes || null; + } else if (key) { try { decryptedDescription = await decrypt( transaction.description, @@ -636,7 +641,10 @@ export default function Transactions({ let decryptedDescription = ''; let decryptedNotes: string | null = null; - if (key) { + if (!transaction.description_iv) { + decryptedDescription = transaction.description; + decryptedNotes = transaction.notes || null; + } else if (key) { try { decryptedDescription = await decrypt( transaction.description, @@ -716,18 +724,23 @@ export default function Transactions({ let decryptedNotes: string | null = null; try { - decryptedDescription = await decrypt( - tx.description, - key, - tx.description_iv, - ); - - if (tx.notes && tx.notes_iv) { - decryptedNotes = await decrypt( - tx.notes, + if (!tx.description_iv) { + decryptedDescription = tx.description; + decryptedNotes = tx.notes || null; + } else { + decryptedDescription = await decrypt( + tx.description, key, - tx.notes_iv, + tx.description_iv, ); + + if (tx.notes && tx.notes_iv) { + decryptedNotes = await decrypt( + tx.notes, + key, + tx.notes_iv, + ); + } } } catch { continue; @@ -940,9 +953,17 @@ export default function Transactions({ ); if (combinedNote !== transaction.decryptedNotes) { - const encrypted = await encrypt(combinedNote, key); - finalNotes = encrypted.encrypted; - finalNotesIv = encrypted.iv; + if (isPlaintext) { + finalNotes = combinedNote; + finalNotesIv = null; + } else { + const encrypted = await encrypt( + combinedNote, + key, + ); + finalNotes = encrypted.encrypted; + finalNotesIv = encrypted.iv; + } consoleDebug('Combined notes with rule note'); } else { consoleDebug('Rule note already present, skipping'); @@ -968,7 +989,9 @@ export default function Transactions({ : null; let decryptedNotes = transaction.decryptedNotes; - if (finalNotes && finalNotesIv) { + if (finalNotes && !finalNotesIv) { + decryptedNotes = finalNotes; + } else if (finalNotes && finalNotesIv) { decryptedNotes = await decrypt( finalNotes, key, @@ -1113,9 +1136,17 @@ export default function Transactions({ ); if (combinedNote !== transaction.decryptedNotes) { - const encrypted = await encrypt(combinedNote, key); - finalNotes = encrypted.encrypted; - finalNotesIv = encrypted.iv; + if (isPlaintext) { + finalNotes = combinedNote; + finalNotesIv = null; + } else { + const encrypted = await encrypt( + combinedNote, + key, + ); + finalNotes = encrypted.encrypted; + finalNotesIv = encrypted.iv; + } consoleDebug('Combined notes with rule note'); } else { consoleDebug('Rule note already present, skipping'); @@ -1141,7 +1172,9 @@ export default function Transactions({ : null; let decryptedNotes = transaction.decryptedNotes; - if (finalNotes && finalNotesIv) { + if (finalNotes && !finalNotesIv) { + decryptedNotes = finalNotes; + } else if (finalNotes && finalNotesIv) { decryptedNotes = await decrypt( finalNotes, key, diff --git a/resources/js/services/transaction-sync.ts b/resources/js/services/transaction-sync.ts index bfc5fb30..e16a4f00 100644 --- a/resources/js/services/transaction-sync.ts +++ b/resources/js/services/transaction-sync.ts @@ -225,11 +225,13 @@ class TransactionSyncService { const decryptedTransactions = await Promise.all( transactionsInRange.map(async (t) => { try { - const decryptedDescription = await decrypt( - t.description, - key, - t.description_iv, - ); + const decryptedDescription = t.description_iv + ? await decrypt( + t.description, + key, + t.description_iv, + ) + : 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 0998754f..e5154f19 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -41,6 +41,7 @@ export interface NavDivider { export interface Features { cashflow: boolean; budgets: boolean; + 'plaintext-transactions': boolean; } export interface SharedData { diff --git a/resources/js/types/transaction.ts b/resources/js/types/transaction.ts index 933ab9b3..fb0649a8 100644 --- a/resources/js/types/transaction.ts +++ b/resources/js/types/transaction.ts @@ -11,7 +11,7 @@ export interface Transaction { account_id: UUID; category_id: UUID | null; description: string; - description_iv: string; + description_iv: string | null; transaction_date: string; amount: number; currency_code: string; diff --git a/tests/Feature/PlaintextTransactionsTest.php b/tests/Feature/PlaintextTransactionsTest.php new file mode 100644 index 00000000..50ea68a5 --- /dev/null +++ b/tests/Feature/PlaintextTransactionsTest.php @@ -0,0 +1,177 @@ +onboarded()->create(); + + expect(Feature::for($user)->active('plaintext-transactions'))->toBeFalse(); +}); + +test('creating transaction without description_iv fails when flag is inactive', function () { + $user = User::factory()->onboarded()->create(); + $account = Account::factory()->create(['user_id' => $user->id]); + + $response = actingAs($user)->postJson(route('transactions.store'), [ + 'account_id' => $account->id, + 'description' => 'Grocery shopping', + 'transaction_date' => '2025-11-11', + 'amount' => 5000, + 'currency_code' => 'USD', + 'source' => 'manually_created', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['description_iv']); +}); + +test('creating plaintext transaction succeeds when flag is active', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('plaintext-transactions'); + $account = Account::factory()->create(['user_id' => $user->id]); + + $response = actingAs($user)->postJson(route('transactions.store'), [ + 'account_id' => $account->id, + 'description' => 'Grocery shopping', + 'transaction_date' => '2025-11-11', + 'amount' => 5000, + 'currency_code' => 'USD', + 'source' => 'manually_created', + ]); + + $response->assertCreated(); + $this->assertDatabaseHas('transactions', [ + 'user_id' => $user->id, + 'description' => 'Grocery shopping', + 'description_iv' => null, + ]); +}); + +test('creating plaintext transaction with notes succeeds when flag is active', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('plaintext-transactions'); + $account = Account::factory()->create(['user_id' => $user->id]); + + $response = actingAs($user)->postJson(route('transactions.store'), [ + 'account_id' => $account->id, + 'description' => 'Coffee', + 'transaction_date' => '2025-11-11', + 'amount' => 350, + 'currency_code' => 'USD', + 'notes' => 'Morning coffee at the cafe', + 'source' => 'manually_created', + ]); + + $response->assertCreated(); + $this->assertDatabaseHas('transactions', [ + 'user_id' => $user->id, + 'description' => 'Coffee', + 'description_iv' => null, + 'notes' => 'Morning coffee at the cafe', + 'notes_iv' => null, + ]); +}); + +test('encrypted transactions still work when flag is active', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('plaintext-transactions'); + $account = Account::factory()->create(['user_id' => $user->id]); + + $response = actingAs($user)->postJson(route('transactions.store'), [ + 'account_id' => $account->id, + 'description' => 'encrypted_content', + 'description_iv' => str_repeat('d', 16), + 'transaction_date' => '2025-11-11', + 'amount' => 1000, + 'currency_code' => 'USD', + 'source' => 'manually_created', + ]); + + $response->assertCreated(); + $this->assertDatabaseHas('transactions', [ + 'user_id' => $user->id, + 'description' => 'encrypted_content', + 'description_iv' => str_repeat('d', 16), + ]); +}); + +test('encrypted and plaintext transactions can coexist', function () { + $user = User::factory()->onboarded()->create(); + $account = Account::factory()->create(['user_id' => $user->id]); + + // Create an encrypted transaction (before flag was activated) + Transaction::factory()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'description' => 'encrypted_content', + 'description_iv' => str_repeat('e', 16), + ]); + + // Activate the flag and create a plaintext transaction + Feature::for($user)->activate('plaintext-transactions'); + + Transaction::factory()->plaintext()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'description' => 'Plaintext transaction', + ]); + + expect(Transaction::where('user_id', $user->id)->count())->toBe(2); + expect(Transaction::where('user_id', $user->id)->whereNull('description_iv')->count())->toBe(1); + expect(Transaction::where('user_id', $user->id)->whereNotNull('description_iv')->count())->toBe(1); +}); + +test('updating transaction without description_iv works when flag is active', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('plaintext-transactions'); + $account = Account::factory()->create(['user_id' => $user->id]); + + $transaction = Transaction::factory()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + ]); + + $response = actingAs($user)->patchJson(route('transactions.update', $transaction), [ + 'description' => 'Updated plaintext', + 'description_iv' => null, + 'notes' => 'Updated notes', + 'notes_iv' => null, + ]); + + $response->assertSuccessful(); + $this->assertDatabaseHas('transactions', [ + 'id' => $transaction->id, + 'description' => 'Updated plaintext', + 'description_iv' => null, + 'notes' => 'Updated notes', + 'notes_iv' => null, + ]); +}); + +test('plaintext-transactions feature flag is shared with frontend', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('plaintext-transactions'); + + $response = actingAs($user)->get(route('transactions.index')); + + $response->assertSuccessful(); + $response->assertInertia(fn ($page) => $page + ->where('features.plaintext-transactions', true) + ); +}); + +test('plaintext-transactions feature flag defaults to false in frontend', function () { + $user = User::factory()->onboarded()->create(); + + $response = actingAs($user)->get(route('transactions.index')); + + $response->assertSuccessful(); + $response->assertInertia(fn ($page) => $page + ->where('features.plaintext-transactions', false) + ); +});