# Universal End-to-End Encryption System ## Extending E2E Encryption to All Sensitive Data ## Executive Summary This document extends the simplified E2E encryption plan to create a **universal encryption system** that works for: - ✅ WireChat messages (already planned) - ✅ Transaction descriptions (new) - ✅ Future sensitive data fields (extensible design) The system uses a **polymorphic, trait-based architecture** that makes any model field encryptable with minimal code changes. ## 1. Universal Design Philosophy ### 1.1 Core Principles **Polymorphic Encryption:** - Any model can have encrypted fields - Single encryption key management system - Consistent encryption/decryption flow **Recipient-Based Access:** - Data encrypted for specific users/participants - Each recipient gets their own encrypted key - Flexible permission system **Transparent to Application:** - Encryption/decryption happens automatically - No changes to existing business logic - Backward compatible with plaintext data **Future-Proof:** - Easy to add encryption to new models - Extensible architecture - Version-aware for algorithm upgrades ### 1.2 Use Cases | Data Type | Who Can Decrypt | Example | |-----------|----------------|---------| | WireChat Message | Conversation participants | Private chat between users | | Transaction Description | From & To account owners | Payment description | | Post Content | Post author + moderators | Private posts | | Profile Notes | Profile owner + admins | Sensitive personal info | | Document Attachments | Document owner + shared users | Private files | ## 2. Universal Encryption Architecture ### 2.1 Polymorphic Schema Design **Single Universal Tables:** ```sql -- Universal encryption keys table (replaces user_encryption_keys) CREATE TABLE encryption_keys ( id BIGINT PRIMARY KEY AUTO_INCREMENT, owner_id BIGINT NOT NULL COMMENT 'ID of the owner (user, org, etc)', owner_type VARCHAR(255) NOT NULL COMMENT 'Model class of owner', public_key TEXT NOT NULL, encrypted_private_key TEXT NOT NULL COMMENT 'Encrypted with Laravel APP_KEY', key_version VARCHAR(10) DEFAULT 'v1', is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP, updated_at TIMESTAMP, INDEX idx_owner (owner_id, owner_type), UNIQUE KEY unique_active_key (owner_id, owner_type, is_active) ); -- Universal encrypted data keys table (replaces message_encryption_keys) CREATE TABLE encrypted_data_keys ( id BIGINT PRIMARY KEY AUTO_INCREMENT, encryptable_id BIGINT NOT NULL COMMENT 'ID of encrypted record', encryptable_type VARCHAR(255) NOT NULL COMMENT 'Model class (Message, Transaction, etc)', encryptable_field VARCHAR(100) NOT NULL COMMENT 'Field name (body, description, etc)', recipient_id BIGINT NOT NULL COMMENT 'Who can decrypt this', recipient_type VARCHAR(255) NOT NULL COMMENT 'Recipient model class', encrypted_data_key TEXT NOT NULL COMMENT 'AES key encrypted with recipient RSA key', nonce VARCHAR(255) NOT NULL, algorithm VARCHAR(50) DEFAULT 'AES-256-GCM', created_at TIMESTAMP, updated_at TIMESTAMP, -- Polymorphic indexes INDEX idx_encryptable (encryptable_id, encryptable_type, encryptable_field), INDEX idx_recipient (recipient_id, recipient_type), -- Ensure one key per recipient per field UNIQUE KEY unique_recipient_key ( encryptable_id, encryptable_type, encryptable_field, recipient_id, recipient_type ) ); -- Encryption metadata on encrypted models (added via trait) -- Each model that uses encryption gets these columns via migration: -- - is_encrypted BOOLEAN DEFAULT FALSE -- - encryption_version VARCHAR(10) NULL -- - encrypted_fields JSON NULL (list of which fields are encrypted) ``` ### 2.2 Transaction-Specific Schema ```sql -- Add encryption support to transactions table ALTER TABLE transactions ADD COLUMN is_encrypted BOOLEAN DEFAULT FALSE AFTER description, ADD COLUMN encryption_version VARCHAR(10) NULL AFTER is_encrypted, ADD COLUMN encrypted_fields JSON NULL AFTER encryption_version, ADD INDEX idx_is_encrypted (is_encrypted); -- Example encrypted_fields JSON: ["description"] -- Could expand to: ["description", "from_reference", "to_reference"] ``` ## 3. Universal Encryption Trait ### 3.1 PHP Trait for Models ```php // app/Traits/HasEncryptedFields.php namespace App\Traits; use App\Models\EncryptedDataKey; use App\Models\EncryptionKey; use Illuminate\Support\Facades\Log; trait HasEncryptedFields { /** * Define which fields should be encrypted * Override in your model */ protected function getEncryptableFields(): array { return $this->encryptable ?? []; } /** * Define who can decrypt this model's data * Override in your model */ abstract protected function getEncryptionRecipients(): array; /** * Boot the trait */ protected static function bootHasEncryptedFields() { // After model is retrieved, mark encrypted fields static::retrieved(function ($model) { if ($model->is_encrypted) { $encryptedFields = json_decode($model->encrypted_fields, true) ?? []; foreach ($encryptedFields as $field) { $model->setAttribute("{$field}_is_encrypted", true); } } }); // After model is saved, handle encryption static::saved(function ($model) { if ($model->shouldEncrypt()) { Log::info('Encrypted data saved', [ 'model' => get_class($model), 'id' => $model->id, 'fields' => $model->encrypted_fields ]); } }); } /** * Check if this model should use encryption */ public function shouldEncrypt(): bool { return $this->is_encrypted ?? false; } /** * Get encryption keys for recipients of this record */ public function getRecipientKeys($field = null) { $recipients = $this->getEncryptionRecipients(); return collect($recipients)->map(function ($recipient) use ($field) { $key = EncryptionKey::where([ 'owner_id' => $recipient->id, 'owner_type' => get_class($recipient), 'is_active' => true ])->first(); if (!$key) { return null; } return [ 'id' => $recipient->id, 'type' => get_class($recipient), 'public_key' => $key->public_key, 'field' => $field ]; })->filter()->values(); } /** * Get decryption key for current user for specific field */ public function getDecryptionKey(string $field) { $user = auth()->user(); if (!$user) { return null; } return EncryptedDataKey::where([ 'encryptable_id' => $this->id, 'encryptable_type' => get_class($this), 'encryptable_field' => $field, 'recipient_id' => $user->id, 'recipient_type' => get_class($user) ])->first(); } /** * Store encryption keys for a field */ public function storeEncryptionKeys(string $field, array $recipientKeysData) { foreach ($recipientKeysData as $keyData) { EncryptedDataKey::updateOrCreate( [ 'encryptable_id' => $this->id, 'encryptable_type' => get_class($this), 'encryptable_field' => $field, 'recipient_id' => $keyData['recipient_id'], 'recipient_type' => $keyData['recipient_type'] ], [ 'encrypted_data_key' => $keyData['encrypted_key'], 'nonce' => $keyData['nonce'], 'algorithm' => 'AES-256-GCM' ] ); } // Update encrypted_fields JSON $encryptedFields = json_decode($this->encrypted_fields, true) ?? []; if (!in_array($field, $encryptedFields)) { $encryptedFields[] = $field; $this->encrypted_fields = json_encode($encryptedFields); $this->is_encrypted = true; $this->encryption_version = 'v1'; $this->saveQuietly(); // Save without triggering events } } /** * Check if user can decrypt this record */ public function canDecrypt($user = null, string $field = null): bool { $user = $user ?? auth()->user(); if (!$user) { return false; } // Check if user is a recipient $recipients = $this->getEncryptionRecipients(); foreach ($recipients as $recipient) { if ($recipient->id === $user->id && get_class($recipient) === get_class($user)) { return true; } } return false; } } ``` ### 3.2 Apply to Message Model ```php // app/Models/Message.php (WireChat override) namespace App\Models; use App\Traits\HasEncryptedFields; use Namu\WireChat\Models\Message as BaseMessage; class Message extends BaseMessage { use HasEncryptedFields; protected $encryptable = ['body']; protected function getEncryptionRecipients(): array { // All conversation participants can decrypt return $this->conversation ->participants() ->with('participantable') ->get() ->pluck('participantable') ->filter() ->values() ->all(); } } ``` ### 3.3 Apply to Transaction Model ```php // app/Models/Transaction.php (extend existing) namespace App\Models; use App\Traits\HasEncryptedFields; use Illuminate\Database\Eloquent\Model; class Transaction extends Model { use HasEncryptedFields; protected $encryptable = ['description']; // Can add 'from_reference', 'to_reference' later protected function getEncryptionRecipients(): array { $recipients = []; // From account owner(s) if ($this->accountFrom && $this->accountFrom->accountable) { $recipients = array_merge($recipients, $this->accountFrom->accountable->all()); } // To account owner(s) if ($this->accountTo && $this->accountTo->accountable) { $recipients = array_merge($recipients, $this->accountTo->accountable->all()); } return array_filter($recipients); } /** * Override toSearchableArray to NOT index encrypted description */ public function toSearchableArray() { $array = parent::toSearchableArray(); // Don't index encrypted descriptions in Elasticsearch if ($this->is_encrypted && in_array('description', json_decode($this->encrypted_fields, true) ?? [])) { $array['description'] = '[Encrypted]'; } return $array; } } ``` ## 4. Universal Frontend Encryption Module ### 4.1 Enhanced Encryption Service ```javascript // resources/js/encryption/universal-encryption.js class UniversalEncryption { constructor() { this.initialized = false; this.myKeys = null; this.keyCache = new Map(); // Cache recipient public keys } async initialize() { if (this.initialized) return; try { const response = await axios.get('/encryption/my-keys'); this.myKeys = response.data; this.initialized = true; console.log('Universal encryption initialized'); } catch (error) { if (error.response?.status === 404) { console.log('No encryption keys found - will generate on first use'); } else { console.error('Failed to initialize encryption:', error); } } } async ensureKeys() { if (this.myKeys) return this.myKeys; console.log('Generating encryption keys...'); const keyPair = await crypto.subtle.generateKey( { name: "RSA-OAEP", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, true, ["encrypt", "decrypt"] ); const publicKey = await crypto.subtle.exportKey("jwk", keyPair.publicKey); const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey); await axios.post('/encryption/store-keys', { public_key: JSON.stringify(publicKey), private_key: JSON.stringify(privateKey) }); this.myKeys = { public_key: JSON.stringify(publicKey), private_key: JSON.stringify(privateKey) }; console.log('Encryption keys generated'); return this.myKeys; } /** * Universal encryption method for any data * * @param {string} plaintext - Data to encrypt * @param {Array} recipients - Array of {id, type, public_key} * @returns {Object} {encryptedData, recipientKeys} */ async encryptData(plaintext, recipients) { await this.ensureKeys(); // Generate ephemeral AES key const dataKey = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"] ); // Encrypt data const nonce = crypto.getRandomValues(new Uint8Array(12)); const encodedData = new TextEncoder().encode(plaintext); const encryptedData = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce }, dataKey, encodedData ); // Export and encrypt data key for each recipient const rawDataKey = await crypto.subtle.exportKey("raw", dataKey); const recipientKeys = []; for (const recipient of recipients) { if (!recipient.public_key) { console.warn('Recipient missing public key:', recipient); continue; } const recipientPublicKey = await crypto.subtle.importKey( "jwk", JSON.parse(recipient.public_key), { name: "RSA-OAEP", hash: "SHA-256" }, false, ["encrypt"] ); const encryptedDataKey = await crypto.subtle.encrypt( { name: "RSA-OAEP" }, recipientPublicKey, rawDataKey ); recipientKeys.push({ recipient_id: recipient.id, recipient_type: recipient.type, encrypted_key: this.arrayBufferToBase64(encryptedDataKey), nonce: this.arrayBufferToBase64(nonce) }); } return { encryptedData: this.arrayBufferToBase64(encryptedData), recipientKeys: recipientKeys, nonce: this.arrayBufferToBase64(nonce) }; } /** * Universal decryption method for any data * * @param {string} encryptedData - Base64 encoded encrypted data * @param {string} encryptedDataKey - Base64 encoded encrypted AES key * @param {string} nonce - Base64 encoded nonce/IV * @returns {string} Decrypted plaintext */ async decryptData(encryptedData, encryptedDataKey, nonce) { await this.ensureKeys(); // Import my private key const privateKey = await crypto.subtle.importKey( "jwk", JSON.parse(this.myKeys.private_key), { name: "RSA-OAEP", hash: "SHA-256" }, false, ["decrypt"] ); // Decrypt data key const encryptedKeyBuffer = this.base64ToArrayBuffer(encryptedDataKey); const dataKeyBuffer = await crypto.subtle.decrypt( { name: "RSA-OAEP" }, privateKey, encryptedKeyBuffer ); // Import data key const dataKey = await crypto.subtle.importKey( "raw", dataKeyBuffer, { name: "AES-GCM", length: 256 }, false, ["decrypt"] ); // Decrypt data const nonceBuffer = this.base64ToArrayBuffer(nonce); const encryptedDataBuffer = this.base64ToArrayBuffer(encryptedData); const decryptedBuffer = await crypto.subtle.decrypt( { name: "AES-GCM", iv: nonceBuffer }, dataKey, encryptedDataBuffer ); return new TextDecoder().decode(decryptedBuffer); } /** * Encrypt a model field * * @param {string} modelType - Model class name * @param {number} modelId - Model ID * @param {string} field - Field name to encrypt * @param {string} value - Value to encrypt * @returns {Object} Encryption data to send to server */ async encryptModelField(modelType, modelId, field, value) { // Get recipients for this model const response = await axios.get(`/encryption/recipients/${modelType}/${modelId}`); const recipients = response.data; if (recipients.length === 0) { throw new Error('No recipients found for encryption'); } // Encrypt the data const encrypted = await this.encryptData(value, recipients); return { model_type: modelType, model_id: modelId, field: field, encrypted_value: encrypted.encryptedData, recipient_keys: encrypted.recipientKeys }; } /** * Decrypt a model field * * @param {string} modelType - Model class name * @param {number} modelId - Model ID * @param {string} field - Field name to decrypt * @param {string} encryptedValue - Encrypted value * @returns {string} Decrypted value */ async decryptModelField(modelType, modelId, field, encryptedValue) { // Get my decryption key for this field const response = await axios.get(`/encryption/key/${modelType}/${modelId}/${field}`); const keyData = response.data; // Decrypt return await this.decryptData( encryptedValue, keyData.encrypted_data_key, keyData.nonce ); } // Helper methods arrayBufferToBase64(buffer) { return btoa(String.fromCharCode(...new Uint8Array(buffer))); } base64ToArrayBuffer(base64) { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } } // Global instance window.universalEncryption = new UniversalEncryption(); ``` ### 4.2 Transaction Form Integration ```javascript // resources/js/encryption/transaction-encryption.js // Initialize on page load document.addEventListener('DOMContentLoaded', async () => { await window.universalEncryption.initialize(); }); // Hook into transaction form submission document.addEventListener('livewire:init', () => { Livewire.hook('commit', async ({ component, commit, respond }) => { // Check if this is a transaction form if (component.fingerprint.name.includes('transaction') && component.description) { try { // Get transaction recipients (from/to account owners) const recipients = await axios.get(`/encryption/transaction-recipients`, { params: { from_account_id: component.from_account_id, to_account_id: component.to_account_id } }); // Encrypt description const encrypted = await window.universalEncryption.encryptData( component.description, recipients.data ); // Replace description with encrypted version component.description = encrypted.encryptedData; // Store encryption metadata component.encryptionData = { field: 'description', recipient_keys: encrypted.recipientKeys, is_encrypted: true }; console.log('Transaction description encrypted'); } catch (error) { console.error('Failed to encrypt transaction:', error); // Allow fallback to plaintext } } }); // Decrypt transactions when loaded Livewire.hook('message.received', async ({ component }) => { if (component.fingerprint.name.includes('transaction')) { await decryptTransactions(component); } }); }); async function decryptTransactions(component) { // Find all encrypted transaction elements const encryptedTransactions = component.$wire.transactions?.filter(t => t.is_encrypted) || []; for (const transaction of encryptedTransactions) { if (transaction.description && transaction.description_is_encrypted) { try { transaction.description = await window.universalEncryption.decryptModelField( 'App\\Models\\Transaction', transaction.id, 'description', transaction.description ); transaction.decryption_failed = false; } catch (error) { console.error('Failed to decrypt transaction:', transaction.id, error); transaction.description = '[Encrypted - Cannot Decrypt]'; transaction.decryption_failed = true; } } } } ``` ## 5. Backend API Controllers ### 5.1 Universal Encryption Controller ```php // app/Http/Controllers/UniversalEncryptionController.php namespace App\Http\Controllers; use App\Models\EncryptedDataKey; use App\Models\EncryptionKey; use App\Models\Transaction; use Illuminate\Http\Request; class UniversalEncryptionController extends Controller { /** * Get encryption keys for the authenticated user */ public function getMyKeys() { $user = auth()->user(); $keys = EncryptionKey::where([ 'owner_id' => $user->id, 'owner_type' => get_class($user), 'is_active' => true ])->first(); if (!$keys) { return response()->json(['error' => 'No keys found'], 404); } return response()->json([ 'public_key' => $keys->public_key, 'private_key' => decrypt($keys->encrypted_private_key) ]); } /** * Store encryption keys */ public function storeKeys(Request $request) { $user = auth()->user(); EncryptionKey::updateOrCreate( [ 'owner_id' => $user->id, 'owner_type' => get_class($user), 'is_active' => true ], [ 'public_key' => $request->public_key, 'encrypted_private_key' => encrypt($request->private_key), 'key_version' => 'v1' ] ); return response()->json(['success' => true]); } /** * Get recipients for a model (for encryption) */ public function getRecipients(string $modelType, int $modelId) { $model = $this->findModel($modelType, $modelId); if (!method_exists($model, 'getEncryptionRecipients')) { return response()->json(['error' => 'Model does not support encryption'], 400); } $recipients = $model->getEncryptionRecipients(); return response()->json( collect($recipients)->map(function ($recipient) { $key = EncryptionKey::where([ 'owner_id' => $recipient->id, 'owner_type' => get_class($recipient), 'is_active' => true ])->first(); return [ 'id' => $recipient->id, 'type' => get_class($recipient), 'public_key' => $key->public_key ?? null ]; })->filter(fn($r) => $r['public_key'] !== null)->values() ); } /** * Get decryption key for a specific model field */ public function getDecryptionKey(string $modelType, int $modelId, string $field) { $user = auth()->user(); $model = $this->findModel($modelType, $modelId); // Check permission if (!method_exists($model, 'canDecrypt') || !$model->canDecrypt($user, $field)) { return response()->json(['error' => 'Access denied'], 403); } $key = EncryptedDataKey::where([ 'encryptable_id' => $modelId, 'encryptable_type' => $modelType, 'encryptable_field' => $field, 'recipient_id' => $user->id, 'recipient_type' => get_class($user) ])->first(); if (!$key) { return response()->json(['error' => 'Key not found'], 404); } return response()->json([ 'encrypted_data_key' => $key->encrypted_data_key, 'nonce' => $key->nonce ]); } /** * Get transaction recipients (specialized endpoint for transactions) */ public function getTransactionRecipients(Request $request) { $fromAccountId = $request->from_account_id; $toAccountId = $request->to_account_id; $recipients = []; // Get from account owners if ($fromAccountId) { $fromAccount = \App\Models\Account::with('accountable')->find($fromAccountId); if ($fromAccount && $fromAccount->accountable) { $recipients = array_merge($recipients, $fromAccount->accountable->all()); } } // Get to account owners if ($toAccountId) { $toAccount = \App\Models\Account::with('accountable')->find($toAccountId); if ($toAccount && $toAccount->accountable) { $recipients = array_merge($recipients, $toAccount->accountable->all()); } } $recipients = array_unique($recipients, SORT_REGULAR); return response()->json( collect($recipients)->map(function ($recipient) { $key = EncryptionKey::where([ 'owner_id' => $recipient->id, 'owner_type' => get_class($recipient), 'is_active' => true ])->first(); return [ 'id' => $recipient->id, 'type' => get_class($recipient), 'public_key' => $key->public_key ?? null ]; })->filter(fn($r) => $r['public_key'] !== null)->values() ); } /** * Store encryption keys for a model field */ public function storeFieldKeys(Request $request) { $validated = $request->validate([ 'model_type' => 'required|string', 'model_id' => 'required|integer', 'field' => 'required|string', 'recipient_keys' => 'required|array', 'recipient_keys.*.recipient_id' => 'required|integer', 'recipient_keys.*.recipient_type' => 'required|string', 'recipient_keys.*.encrypted_key' => 'required|string', 'recipient_keys.*.nonce' => 'required|string', ]); $model = $this->findModel($validated['model_type'], $validated['model_id']); if (!method_exists($model, 'storeEncryptionKeys')) { return response()->json(['error' => 'Model does not support encryption'], 400); } $model->storeEncryptionKeys($validated['field'], $validated['recipient_keys']); return response()->json(['success' => true]); } /** * Helper to find and authorize model access */ protected function findModel(string $modelType, int $modelId) { // Whitelist allowed models for security $allowedModels = [ 'App\\Models\\Transaction', 'App\\Models\\Message', 'App\\Models\\Post', // Future // Add more as needed ]; if (!in_array($modelType, $allowedModels)) { abort(400, 'Invalid model type'); } $model = $modelType::find($modelId); if (!$model) { abort(404, 'Model not found'); } return $model; } } ``` ### 5.2 Routes ```php // routes/encryption.php use App\Http\Controllers\UniversalEncryptionController; Route::middleware(['auth'])->prefix('encryption')->group(function () { // Key management Route::post('/store-keys', [UniversalEncryptionController::class, 'storeKeys']); Route::get('/my-keys', [UniversalEncryptionController::class, 'getMyKeys']); // Universal encryption/decryption Route::get('/recipients/{modelType}/{modelId}', [UniversalEncryptionController::class, 'getRecipients']); Route::get('/key/{modelType}/{modelId}/{field}', [UniversalEncryptionController::class, 'getDecryptionKey']); Route::post('/store-field-keys', [UniversalEncryptionController::class, 'storeFieldKeys']); // Transaction-specific helpers Route::get('/transaction-recipients', [UniversalEncryptionController::class, 'getTransactionRecipients']); }); ``` ## 6. Livewire Components ### 6.1 Transaction Form Component ```php // app/Livewire/Transactions/CreateTransaction.php namespace App\Livewire\Transactions; use App\Models\Transaction; use Livewire\Component; class CreateTransaction extends Component { public $from_account_id; public $to_account_id; public $amount; public $description; public $encryptionData = null; public function save() { $this->validate([ 'from_account_id' => 'required|exists:accounts,id', 'to_account_id' => 'required|exists:accounts,id', 'amount' => 'required|numeric|min:0', 'description' => 'nullable|string', ]); // Create transaction $transaction = Transaction::create([ 'from_account_id' => $this->from_account_id, 'to_account_id' => $this->to_account_id, 'amount' => $this->amount, 'description' => $this->description, // Already encrypted by frontend 'creator_user_id' => auth()->id(), 'is_encrypted' => !empty($this->encryptionData), 'encryption_version' => 'v1' ]); // Store encryption keys if encrypted if ($this->encryptionData) { $transaction->storeEncryptionKeys( $this->encryptionData['field'], $this->encryptionData['recipient_keys'] ); } session()->flash('message', 'Transaction created successfully'); return redirect()->route('transactions.index'); } public function render() { return view('livewire.transactions.create-transaction'); } } ``` ### 6.2 Transaction List Component ```php // app/Livewire/Transactions/TransactionList.php namespace App\Livewire\Transactions; use App\Models\Transaction; use Livewire\Component; use Livewire\WithPagination; class TransactionList extends Component { use WithPagination; public function render() { $user = auth()->user(); // Get user's accounts $accountIds = $user->accounts->pluck('id'); // Get transactions for user's accounts $transactions = Transaction::whereIn('from_account_id', $accountIds) ->orWhereIn('to_account_id', $accountIds) ->with(['accountFrom', 'accountTo']) ->orderBy('created_at', 'desc') ->paginate(20); // Frontend will handle decryption of encrypted descriptions return view('livewire.transactions.transaction-list', [ 'transactions' => $transactions ]); } } ``` ## 7. Blade Templates ### 7.1 Transaction Form ```blade {{-- resources/views/livewire/transactions/create-transaction.blade.php --}}
| Date | From | To | Amount | Description |
|---|---|---|---|---|
| {{ $transaction->created_at->format('Y-m-d') }} | {{ $transaction->accountFrom->name }} | {{ $transaction->accountTo->name }} | {{ $transaction->amount }} | @if($transaction->is_encrypted) Decrypting... @else {{ $transaction->description }} @endif |