feat: Plaintext transactions behind feature flag (#105)

## Summary

- Adds `plaintext-transactions` Pennant feature flag (defaults to
`false` / encryption ON)
- When active, new transactions are stored as plaintext (no client-side
encryption)
- Existing encrypted transactions continue to work — detection is based
on `description_iv` being NULL (plaintext) vs present (encrypted)
- Migration makes `description_iv` nullable on the transactions table

## Changes

**Backend:**
- Feature flag definition in `AppServiceProvider`, shared via
`HandleInertiaRequests`
- `StoreTransactionRequest` / `UpdateTransactionRequest` conditionally
require `description_iv`
- `TransactionFactory` gains a `plaintext()` state

**Frontend:**
- Edit dialog and import drawer skip encryption when flag is active
- `EncryptedTransactionDescription` renders plaintext directly when IV
is null
- All decryption loops handle both encrypted and plaintext transactions
- Rule re-evaluation stores notes as plaintext when flag is active

## Test plan

- [x] Run `php artisan migrate` to apply the migration
- [x] Activate flag: `php artisan feature:enable plaintext-transactions
all`
- [ ] Create a transaction — verify description is stored as plaintext
in DB (`description_iv` is NULL)
- [ ] Deactivate flag: `php artisan feature:disable
plaintext-transactions all`
- [ ] Create a transaction — verify encryption is still required (422
without IV)
- [x] Verify old encrypted transactions still display correctly
- [ ] Run `php artisan test --compact
tests/Feature/PlaintextTransactionsTest.php`
This commit is contained in:
Víctor Falcón 2026-02-10 14:31:24 +01:00 committed by GitHub
parent 1083f60494
commit e35f7125b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 458 additions and 91 deletions

View File

@ -49,6 +49,6 @@ trait ResolvesFeatures
private function getStringBasedFeatures(): array
{
return ['budgets'];
return ['budgets', 'plaintext-transactions'];
}
}

View File

@ -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')

View File

@ -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'],

View File

@ -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'],

View File

@ -40,5 +40,6 @@ class AppServiceProvider extends ServiceProvider
});
Feature::define('budgets', fn (User $user) => true);
Feature::define('plaintext-transactions', fn (User $user) => false);
}
}

View File

@ -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,
]);
}
}

View File

@ -0,0 +1,28 @@
<?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->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();
});
}
};

View File

@ -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<SharedData>().props;
const isPlaintext = features['plaintext-transactions'];
const [transactionDate, setTransactionDate] = useState('');
const [description, setDescription] = useState('');
const [amount, setAmount] = useState<number>(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;
}

View File

@ -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 <span className={className}>{fakeDescription}</span>;
}
return <span className={className}>{encryptedText}</span>;
}
if (isPrivacyModeEnabled && isKeySet) {
return <span className={className}>{fakeDescription}</span>;
}

View File

@ -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<SharedData>().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 &&

View File

@ -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<SharedData>().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,

View File

@ -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<SharedData>().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;
}
}
}

View File

@ -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,

View File

@ -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<SharedData>().props;
const isPlaintext = features['plaintext-transactions'];
const [transactions, setTransactions] = useState<DecryptedTransaction[]>(
[],
@ -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,

View File

@ -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),

View File

@ -41,6 +41,7 @@ export interface NavDivider {
export interface Features {
cashflow: boolean;
budgets: boolean;
'plaintext-transactions': boolean;
}
export interface SharedData {

View File

@ -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;

View File

@ -0,0 +1,177 @@
<?php
use App\Models\Account;
use App\Models\Transaction;
use App\Models\User;
use Laravel\Pennant\Feature;
use function Pest\Laravel\actingAs;
test('plaintext-transactions feature flag defaults to false', function () {
$user = User::factory()->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)
);
});