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:
parent
1083f60494
commit
e35f7125b3
|
|
@ -49,6 +49,6 @@ trait ResolvesFeatures
|
|||
|
||||
private function getStringBasedFeatures(): array
|
||||
{
|
||||
return ['budgets'];
|
||||
return ['budgets', 'plaintext-transactions'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -40,5 +40,6 @@ class AppServiceProvider extends ServiceProvider
|
|||
});
|
||||
|
||||
Feature::define('budgets', fn (User $user) => true);
|
||||
Feature::define('plaintext-transactions', fn (User $user) => false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export interface NavDivider {
|
|||
export interface Features {
|
||||
cashflow: boolean;
|
||||
budgets: boolean;
|
||||
'plaintext-transactions': boolean;
|
||||
}
|
||||
|
||||
export interface SharedData {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue