From 9bd1fcea3711ca970c7e8ef5f18150a308addcad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 7 Jan 2026 10:58:14 +0100 Subject: [PATCH] Demo Account Experience (#51) ## Summary whispermoney test_ Introduces a fully-functional demo account that lets prospective users explore Whisper Money without creating an account. Users can click "Check Demo" on the welcome page to instantly access a pre-populated account with realistic financial data spanning 12 months. ## What's New **Try Before You Sign Up** - New "Check Demo" button on the welcome page for instant access - Pre-configured demo account with real-world financial scenarios - 12 months of sample transactions across multiple account types (checking, savings, credit cards, investments) - Pre-built automation rules, labels, and categories to showcase the full app experience **Demo Account Limitations** - Demo accounts are read-only for sensitive operations (can't change password, email, or payment settings) - Clear messaging throughout the UI when demo restrictions apply - Settings pages show helpful notices about demo limitations - Automatic daily reset to maintain fresh demo experience **Developer Experience** - `php artisan demo:reset` command for manual resets - Configurable via environment variables (DEMO_EMAIL, DEMO_PASSWORD, DEMO_ENCRYPTION_KEY) - Comprehensive test coverage for demo restrictions and data generation ## User Impact This feature removes the friction of signing up before understanding the product's value. Users can: - Explore all features with realistic data - Test automation rules and see them in action - View charts and insights based on a year of financial activity - Experience the full privacy-first encryption workflow - Understand the product before committing to create an account Perfect for demos, screenshots, documentation, and helping users make informed decisions about whether Whisper Money fits their needs. --- .env.example | 5 + .../Commands/ResetDemoAccountCommand.php | 394 ++++++++++++++++++ .../Controllers/SubscriptionController.php | 5 + .../Middleware/BlockDemoAccountActions.php | 48 +++ app/Http/Middleware/HandleInertiaRequests.php | 8 + app/Models/User.php | 5 + .../Demo/DemoAutomationRulesProvider.php | 67 +++ app/Services/Demo/DemoEncryptionService.php | 102 +++++ app/Services/Demo/DemoLabelsProvider.php | 32 ++ .../Demo/DemoTransactionsProvider.php | 150 +++++++ bootstrap/app.php | 2 + config/app.php | 6 + database/seeders/data/banks.json | 12 + resources/js/components/delete-user.tsx | 24 +- .../js/components/unlock-message-dialog.tsx | 10 + resources/js/layouts/settings/layout.tsx | 42 +- resources/js/pages/auth/login.tsx | 23 +- resources/js/pages/settings/billing.tsx | 33 +- resources/js/pages/settings/password.tsx | 20 +- resources/js/pages/settings/two-factor.tsx | 106 +++-- resources/js/pages/welcome.tsx | 26 +- resources/js/types/index.d.ts | 1 + routes/console.php | 1 + routes/settings.php | 6 +- .../Console/ResetDemoAccountCommandTest.php | 228 ++++++++++ tests/Feature/DemoAccountRestrictionsTest.php | 89 ++++ tests/Unit/DemoEncryptionServiceTest.php | 85 ++++ 27 files changed, 1448 insertions(+), 82 deletions(-) create mode 100644 app/Console/Commands/ResetDemoAccountCommand.php create mode 100644 app/Http/Middleware/BlockDemoAccountActions.php create mode 100644 app/Services/Demo/DemoAutomationRulesProvider.php create mode 100644 app/Services/Demo/DemoEncryptionService.php create mode 100644 app/Services/Demo/DemoLabelsProvider.php create mode 100644 app/Services/Demo/DemoTransactionsProvider.php create mode 100644 tests/Feature/Console/ResetDemoAccountCommandTest.php create mode 100644 tests/Feature/DemoAccountRestrictionsTest.php create mode 100644 tests/Unit/DemoEncryptionServiceTest.php diff --git a/.env.example b/.env.example index 02f0e770..eefc6b46 100644 --- a/.env.example +++ b/.env.example @@ -85,3 +85,8 @@ STRIPE_PRO_YEARLY_PRICE_ID= SENTRY_LARAVEL_DSN= SENTRY_TRACES_SAMPLE_RATE=1.0 SENTRY_PROFILES_SAMPLE_RATE=0 + +# Demo Account Configuration +DEMO_EMAIL=demo@whisper.money +DEMO_PASSWORD=demo +DEMO_ENCRYPTION_KEY=demo diff --git a/app/Console/Commands/ResetDemoAccountCommand.php b/app/Console/Commands/ResetDemoAccountCommand.php new file mode 100644 index 00000000..02d9b7de --- /dev/null +++ b/app/Console/Commands/ResetDemoAccountCommand.php @@ -0,0 +1,394 @@ +error('Demo configuration not set. Please set DEMO_EMAIL and DEMO_PASSWORD in .env'); + + return self::FAILURE; + } + + $this->info("Resetting demo account: {$demoEmail}"); + + $salt = $this->encryptionService->generateSalt($demoEncryptionKey); + $this->encryptionKey = $this->encryptionService->deriveKey($demoEncryptionKey, $salt); + + $user = $this->findOrCreateDemoUser($demoEmail, $demoPassword, $salt); + + $this->deleteExistingData($user); + + $this->createEncryptedMessage($user); + + $this->createCategories($user); + + $labels = $this->createLabels($user); + + $this->createAccountsWithTransactions($user, $labels); + + $this->createAutomationRules($user, $labels); + + $this->createSubscription($user); + + $this->info('✓ Demo account reset successfully!'); + + return self::SUCCESS; + } + + private function findOrCreateDemoUser(string $email, string $password, string $salt): User + { + $user = User::where('email', $email)->first(); + + if ($user) { + $user->update(['encryption_salt' => $salt]); + + return $user; + } + + return User::create([ + 'email' => $email, + 'name' => 'Demo User', + 'password' => $password, + 'email_verified_at' => now(), + 'onboarded_at' => now(), + 'encryption_salt' => $salt, + 'currency_code' => 'USD', + ]); + } + + private function deleteExistingData(User $user): void + { + $user->transactions()->forceDelete(); + $user->accounts()->forceDelete(); + $user->labels()->forceDelete(); + $user->automationRules()->forceDelete(); + $user->categories()->forceDelete(); + $user->encryptedMessage()?->delete(); + + $this->info(' Deleted existing data'); + } + + private function createEncryptedMessage(User $user): void + { + $testMessage = $this->encryptionService->encrypt( + 'Hello, world', + $this->encryptionKey, + 'demo_test_message' + ); + + EncryptedMessage::create([ + 'user_id' => $user->id, + 'encrypted_content' => $testMessage['encrypted'], + 'iv' => $testMessage['iv'], + ]); + + $this->info(' Created encrypted message for key verification'); + } + + private function createCategories(User $user): void + { + (new CreateDefaultCategories)->handle($user); + $this->info(' Created default categories'); + } + + /** + * @return array + */ + private function createLabels(User $user): array + { + $labelsConfig = $this->labelsProvider->getLabels(); + $labels = []; + + foreach ($labelsConfig as $labelConfig) { + $label = $user->labels()->create([ + 'name' => $labelConfig['name'], + 'color' => $labelConfig['color'], + ]); + $labels[] = [ + 'label' => $label, + 'assignment_percentage' => $labelConfig['assignment_percentage'], + ]; + } + + $this->info(' Created '.count($labels).' labels'); + + return $labels; + } + + /** + * @param array $labels + */ + private function createAccountsWithTransactions(User $user, array $labels): void + { + $bbvaBank = Bank::query()->whereNull('user_id')->where('name', 'BBVA')->first() + ?? Bank::factory()->create(['user_id' => null]); + $ingBank = Bank::query()->whereNull('user_id')->where('name', 'ING')->first() + ?? Bank::factory()->create(['user_id' => null]); + $indexaCapitalBank = Bank::query()->whereNull('user_id')->where('name', 'Indexa Capital')->first() + ?? Bank::factory()->create(['user_id' => null]); + $binanceBank = Bank::query()->whereNull('user_id')->where('name', 'Binance')->first() + ?? Bank::factory()->create(['user_id' => null]); + $categories = $user->categories()->get()->keyBy('name'); + + $accounts = [ + [ + 'name' => 'Primary Checking', + 'type' => AccountType::Checking, + 'current_balance' => $this->generateRealisticBalance(2000000, 3500000), + 'monthly_variance' => 150000, + 'bank_account_id' => $bbvaBank->id, + ], + [ + 'name' => 'Joint Checking', + 'type' => AccountType::Checking, + 'current_balance' => $this->generateRealisticBalance(500000, 1200000), + 'monthly_variance' => 80000, + 'bank_account_id' => $bbvaBank->id, + ], + [ + 'name' => 'Emergency Fund', + 'type' => AccountType::Savings, + 'current_balance' => $this->generateRealisticBalance(1200000, 1800000), + 'monthly_variance' => 25000, + 'bank_account_id' => $ingBank->id, + ], + [ + 'name' => '401(k) Retirement', + 'type' => AccountType::Retirement, + 'current_balance' => $this->generateRealisticBalance(8500000, 12500000), + 'monthly_variance' => 350000, + 'bank_account_id' => $indexaCapitalBank->id, + ], + [ + 'name' => 'Brokerage Account', + 'type' => AccountType::Investment, + 'current_balance' => $this->generateRealisticBalance(1500000, 3500000), + 'monthly_variance' => 200000, + 'bank_account_id' => $indexaCapitalBank->id, + ], + [ + 'name' => 'Cryptos', + 'type' => AccountType::Investment, + 'current_balance' => $this->generateRealisticBalance(1500000, 4500000), + 'monthly_variance' => 100000, + 'bank_account_id' => $binanceBank->id, + ], + ]; + + $totalTransactions = 0; + + foreach ($accounts as $index => $accountData) { + $encrypted = $this->encryptionService->encrypt( + $accountData['name'], + $this->encryptionKey, + "demo_account_{$index}" + ); + + $account = $user->accounts()->create([ + 'name' => $encrypted['encrypted'], + 'name_iv' => $encrypted['iv'], + 'bank_id' => $accountData['bank_account_id'], + 'currency_code' => 'USD', + 'type' => $accountData['type'], + ]); + + $this->createBalanceHistory($account, $accountData['current_balance'], $accountData['monthly_variance']); + + if ($this->accountTypeHasTransactions($accountData['type'])) { + $transactionCount = $this->createTransactionsForAccount($account, $categories, $labels); + $totalTransactions += $transactionCount; + } + } + + $this->info(" Created 5 accounts with {$totalTransactions} transactions and 12 months of balances"); + } + + private function generateRealisticBalance(int $min, int $max): int + { + $base = rand($min, $max); + $cents = rand(0, 99); + + return (int) (floor($base / 100) * 100 + $cents); + } + + private function createBalanceHistory(Account $account, int $currentBalance, int $monthlyVariance): void + { + $targetFirstMonthBalance = (int) ($currentBalance / (1 + self::MIN_BALANCE_GROWTH_PERCENTAGE)); + $balance = $currentBalance; + $balances = []; + + for ($i = 0; $i <= 12; $i++) { + $date = now()->subMonths($i)->endOfMonth(); + + if ($i === 0) { + $date = now(); + } + + $balances[] = [ + 'date' => $date, + 'balance' => $balance, + ]; + + if ($i < 12) { + $change = rand(-$monthlyVariance, $monthlyVariance); + $balance = max(10000, $balance - $change); + $balance = $this->generateRealisticBalance($balance - 5000, $balance + 5000); + } + } + + $firstMonthBalance = $balances[12]['balance']; + $reductionNeeded = $firstMonthBalance - $targetFirstMonthBalance; + + if ($reductionNeeded > 0) { + $reductionPerMonth = ($reductionNeeded + 100) / 12; + + for ($i = 0; $i <= 12; $i++) { + $monthIndex = $i; + $reduction = (int) ($reductionPerMonth * $monthIndex); + $balances[$i]['balance'] = max(10000, $balances[$i]['balance'] - $reduction); + } + } + + foreach ($balances as $balanceData) { + $account->balances()->create([ + 'balance_date' => $balanceData['date']->format('Y-m-d'), + 'balance' => $balanceData['balance'], + ]); + } + } + + /** + * @param Collection $categories + * @param array $labels + */ + private function createTransactionsForAccount(Account $account, Collection $categories, array $labels): int + { + $transactions = $this->transactionsProvider->getTransactions(); + $count = 0; + + foreach ($transactions as $index => $transactionData) { + $categoryName = $transactionData['category_name']; + unset($transactionData['category_name']); + + $category = $categories->get($categoryName); + + if (! $category) { + continue; + } + + $encrypted = $this->encryptionService->encrypt( + $transactionData['description'], + $this->encryptionKey, + "demo_tx_{$account->id}_{$index}" + ); + + $transactionData['description'] = $encrypted['encrypted']; + $transactionData['description_iv'] = $encrypted['iv']; + + $transaction = $account->transactions()->create([ + 'user_id' => $account->user_id, + 'category_id' => $category->id, + ...$transactionData, + ]); + + foreach ($labels as $labelConfig) { + if (rand(1, 100) <= $labelConfig['assignment_percentage']) { + $transaction->labels()->attach($labelConfig['label']->id); + } + } + + $count++; + } + + return $count; + } + + /** + * @param array $labels + */ + private function createAutomationRules(User $user, array $labels): void + { + $rules = $this->rulesProvider->getRules(); + + foreach ($rules as $ruleData) { + $category = null; + if ($ruleData['category_name']) { + $category = $user->categories()->where('name', $ruleData['category_name'])->first(); + } + + $rule = $user->automationRules()->create([ + 'title' => $ruleData['title'], + 'priority' => $ruleData['priority'], + 'rules_json' => $ruleData['rules_json'], + 'action_category_id' => $category?->id, + 'action_note' => $ruleData['action_note'], + 'action_note_iv' => $ruleData['action_note'] ? 'demo_iv' : null, + ]); + + if (rand(0, 1) && ! empty($labels)) { + $randomLabel = $labels[array_rand($labels)]['label']; + $rule->labels()->attach($randomLabel->id); + } + } + + $this->info(' Created '.count($rules).' automation rules'); + } + + private function accountTypeHasTransactions(AccountType $type): bool + { + return $type !== AccountType::Investment && $type !== AccountType::Retirement; + } + + private function createSubscription(User $user): void + { + $user->subscriptions()->delete(); + + $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_demo_free_forever', + 'stripe_status' => 'active', + 'stripe_price' => 'price_demo_free', + ]); + + $this->info(' Created demo subscription'); + } +} diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index 7de78a78..a08c5559 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -96,6 +96,11 @@ class SubscriptionController extends Controller public function billingPortal(Request $request): RedirectResponse { + if ($request->user()->isDemoAccount()) { + return redirect()->route('settings.billing') + ->withErrors(['demo' => 'Billing management is not available on the demo account.']); + } + return $request->user()->redirectToBillingPortal(route('settings.billing')); } } diff --git a/app/Http/Middleware/BlockDemoAccountActions.php b/app/Http/Middleware/BlockDemoAccountActions.php new file mode 100644 index 00000000..119b0074 --- /dev/null +++ b/app/Http/Middleware/BlockDemoAccountActions.php @@ -0,0 +1,48 @@ +user()?->isDemoAccount()) { + return $next($request); + } + + $path = $request->path(); + $method = $request->method(); + + // When running globally with 'auto' mode, only block specific Fortify routes + // When explicitly applied (mode is null), block all mutating actions + $shouldBlock = $mode === 'auto' + ? (in_array($path, $this->fortifyBlockedRoutes) && in_array($method, $this->blockedMethods)) + : in_array($method, $this->blockedMethods); + + if ($shouldBlock) { + if ($request->expectsJson()) { + return response()->json([ + 'message' => 'This action is not available on the demo account.', + ], 403); + } + + return back()->withErrors([ + 'demo' => 'This action is not available on the demo account.', + ]); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index e4e08c45..ebabf636 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -41,6 +41,8 @@ class HandleInertiaRequests extends Middleware [$message, $author] = str(Inspiring::quotes()->random())->explode('-'); $user = $request->user(); + $isDemoAccount = $user?->isDemoAccount() ?? false; + $isDemoQuery = $request->query('demo') === '1'; return [ ...parent::share($request), @@ -51,7 +53,13 @@ class HandleInertiaRequests extends Middleware 'auth' => [ 'user' => $user, 'hasProPlan' => $user?->hasProPlan() ?? false, + 'isDemoAccount' => $isDemoAccount, ], + 'demoCredentials' => ($isDemoQuery || $isDemoAccount) ? [ + 'email' => config('app.demo.email'), + 'password' => config('app.demo.password'), + ] : null, + 'demoEncryptionKey' => $isDemoAccount ? config('app.demo.encryption_key') : null, 'subscriptionsEnabled' => config('subscriptions.enabled', false), 'pricing' => [ 'plans' => config('subscriptions.plans', []), diff --git a/app/Models/User.php b/app/Models/User.php index cb745c35..ace20721 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -112,4 +112,9 @@ class User extends Authenticatable return $this->subscribed('default'); } + + public function isDemoAccount(): bool + { + return $this->email === config('app.demo.email'); + } } diff --git a/app/Services/Demo/DemoAutomationRulesProvider.php b/app/Services/Demo/DemoAutomationRulesProvider.php new file mode 100644 index 00000000..01d7c826 --- /dev/null +++ b/app/Services/Demo/DemoAutomationRulesProvider.php @@ -0,0 +1,67 @@ +, category_name: string|null, action_note: string|null}> + */ + public function getRules(): array + { + return [ + [ + 'title' => 'Categorize Grocery Stores', + 'priority' => 10, + 'rules_json' => ['in' => ['grocery', ['var' => 'description']]], + 'category_name' => 'Groceries', + 'action_note' => null, + ], + [ + 'title' => 'Categorize Coffee Shops', + 'priority' => 20, + 'rules_json' => ['or' => [ + ['in' => ['starbucks', ['var' => 'description']]], + ['in' => ['coffee', ['var' => 'description']]], + ]], + 'category_name' => 'Cafes, restaurants, bars', + 'action_note' => null, + ], + [ + 'title' => 'Categorize Gas Stations', + 'priority' => 30, + 'rules_json' => ['or' => [ + ['in' => ['gas', ['var' => 'description']]], + ['in' => ['fuel', ['var' => 'description']]], + ['in' => ['shell', ['var' => 'description']]], + ]], + 'category_name' => 'Fuel', + 'action_note' => null, + ], + [ + 'title' => 'Categorize Salary Deposits', + 'priority' => 5, + 'rules_json' => ['and' => [ + ['>' => [['var' => 'amount'], 0]], + ['in' => ['salary', ['var' => 'description']]], + ]], + 'category_name' => 'Salary', + 'action_note' => null, + ], + [ + 'title' => 'Categorize Online Subscriptions', + 'priority' => 40, + 'rules_json' => ['or' => [ + ['in' => ['netflix', ['var' => 'description']]], + ['in' => ['spotify', ['var' => 'description']]], + ['in' => ['subscription', ['var' => 'description']]], + ]], + 'category_name' => 'Online services', + 'action_note' => null, + ], + ]; + } +} diff --git a/app/Services/Demo/DemoEncryptionService.php b/app/Services/Demo/DemoEncryptionService.php new file mode 100644 index 00000000..16f8277f --- /dev/null +++ b/app/Services/Demo/DemoEncryptionService.php @@ -0,0 +1,102 @@ + base64_encode($encryptedWithTag), + 'iv' => base64_encode($iv), + ]; + } + + /** + * Decrypt ciphertext using AES-256-GCM. + */ + public function decrypt(string $encryptedBase64, string $key, string $ivBase64): string + { + $encryptedWithTag = base64_decode($encryptedBase64); + $iv = base64_decode($ivBase64); + + $tagLength = 16; + $encrypted = substr($encryptedWithTag, 0, -$tagLength); + $tag = substr($encryptedWithTag, -$tagLength); + + $decrypted = openssl_decrypt( + $encrypted, + 'aes-256-gcm', + $key, + OPENSSL_RAW_DATA, + $iv, + $tag + ); + + if ($decrypted === false) { + throw new \RuntimeException('Decryption failed'); + } + + return $decrypted; + } +} diff --git a/app/Services/Demo/DemoLabelsProvider.php b/app/Services/Demo/DemoLabelsProvider.php new file mode 100644 index 00000000..581bc1de --- /dev/null +++ b/app/Services/Demo/DemoLabelsProvider.php @@ -0,0 +1,32 @@ + + */ + public function getLabels(): array + { + return [ + [ + 'name' => 'Essential', + 'color' => 'green', + 'assignment_percentage' => 40, + ], + [ + 'name' => 'Recurring', + 'color' => 'blue', + 'assignment_percentage' => 25, + ], + [ + 'name' => 'Review Later', + 'color' => 'amber', + 'assignment_percentage' => 15, + ], + ]; + } +} diff --git a/app/Services/Demo/DemoTransactionsProvider.php b/app/Services/Demo/DemoTransactionsProvider.php new file mode 100644 index 00000000..a3cda5f7 --- /dev/null +++ b/app/Services/Demo/DemoTransactionsProvider.php @@ -0,0 +1,150 @@ + + */ + private const TRANSACTION_TEMPLATES = [ + ['description' => 'Whole Foods Market', 'amount_min' => -15000, 'amount_max' => -8000, 'category_name' => 'Groceries', 'frequency' => 'weekly'], + ['description' => 'Trader Joe\'s', 'amount_min' => -8500, 'amount_max' => -4500, 'category_name' => 'Groceries', 'frequency' => 'weekly'], + ['description' => 'Costco Wholesale', 'amount_min' => -25000, 'amount_max' => -12000, 'category_name' => 'Groceries', 'frequency' => 'monthly'], + ['description' => 'Starbucks Coffee', 'amount_min' => -850, 'amount_max' => -450, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'frequent'], + ['description' => 'Dunkin Donuts', 'amount_min' => -650, 'amount_max' => -350, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'frequent'], + ['description' => 'Shell Gas Station', 'amount_min' => -6500, 'amount_max' => -3500, 'category_name' => 'Fuel', 'frequency' => 'biweekly'], + ['description' => 'Chevron Gas', 'amount_min' => -5800, 'amount_max' => -3200, 'category_name' => 'Fuel', 'frequency' => 'biweekly'], + ['description' => 'Salary Deposit - ACME Corp', 'amount_min' => 485000, 'amount_max' => 485000, 'category_name' => 'Salary', 'frequency' => 'monthly'], + ['description' => 'Electric Company - Monthly Bill', 'amount_min' => -18500, 'amount_max' => -9500, 'category_name' => 'Electricity', 'frequency' => 'monthly'], + ['description' => 'Water & Sewer Utility', 'amount_min' => -7500, 'amount_max' => -4500, 'category_name' => 'Water', 'frequency' => 'monthly'], + ['description' => 'Natural Gas Bill', 'amount_min' => -12000, 'amount_max' => -4500, 'category_name' => 'Natural gas', 'frequency' => 'monthly'], + ['description' => 'Comcast Internet & Cable', 'amount_min' => -15999, 'amount_max' => -12999, 'category_name' => 'Telephone, internet, TV, computer', 'frequency' => 'monthly'], + ['description' => 'T-Mobile Wireless', 'amount_min' => -8500, 'amount_max' => -7500, 'category_name' => 'Telephone, internet, TV, computer', 'frequency' => 'monthly'], + ['description' => 'Netflix Subscription', 'amount_min' => -1599, 'amount_max' => -1599, 'category_name' => 'Online services', 'frequency' => 'monthly'], + ['description' => 'Spotify Premium', 'amount_min' => -1099, 'amount_max' => -1099, 'category_name' => 'Online services', 'frequency' => 'monthly'], + ['description' => 'Amazon Prime', 'amount_min' => -1499, 'amount_max' => -1499, 'category_name' => 'Online services', 'frequency' => 'monthly'], + ['description' => 'Disney+ Subscription', 'amount_min' => -1099, 'amount_max' => -1099, 'category_name' => 'Online services', 'frequency' => 'monthly'], + ['description' => 'Chipotle Mexican Grill', 'amount_min' => -1800, 'amount_max' => -1200, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'weekly'], + ['description' => 'Olive Garden Restaurant', 'amount_min' => -8500, 'amount_max' => -4500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'], + ['description' => 'Thai Palace Dinner', 'amount_min' => -6500, 'amount_max' => -3500, 'category_name' => 'Cafes, restaurants, bars', 'frequency' => 'monthly'], + ['description' => 'DoorDash Delivery', 'amount_min' => -4500, 'amount_max' => -2500, 'category_name' => 'Food delivery', 'frequency' => 'weekly'], + ['description' => 'Uber Eats Order', 'amount_min' => -3800, 'amount_max' => -2200, 'category_name' => 'Food delivery', 'frequency' => 'weekly'], + ['description' => 'Amazon.com Purchase', 'amount_min' => -15000, 'amount_max' => -2500, 'category_name' => 'Online transactions', 'frequency' => 'weekly'], + ['description' => 'Target Store', 'amount_min' => -12000, 'amount_max' => -3500, 'category_name' => 'Household goods', 'frequency' => 'biweekly'], + ['description' => 'Walmart Supercenter', 'amount_min' => -8500, 'amount_max' => -2500, 'category_name' => 'Other groceries', 'frequency' => 'biweekly'], + ['description' => 'CVS Pharmacy', 'amount_min' => -4500, 'amount_max' => -1500, 'category_name' => 'Health and pharmaceuticals', 'frequency' => 'monthly'], + ['description' => 'Walgreens', 'amount_min' => -3500, 'amount_max' => -1200, 'category_name' => 'Health and pharmaceuticals', 'frequency' => 'monthly'], + ['description' => 'Planet Fitness Monthly', 'amount_min' => -2499, 'amount_max' => -2499, 'category_name' => 'Sport and sports goods', 'frequency' => 'monthly'], + ['description' => 'ATM Cash Withdrawal', 'amount_min' => -30000, 'amount_max' => -10000, 'category_name' => 'Cash withdrawal', 'frequency' => 'biweekly'], + ['description' => 'State Farm Insurance', 'amount_min' => -15800, 'amount_max' => -12500, 'category_name' => 'Insurance', 'frequency' => 'monthly'], + ['description' => 'Rent Payment', 'amount_min' => -195000, 'amount_max' => -195000, 'category_name' => 'Rent and maintanence', 'frequency' => 'monthly'], + ['description' => 'Uber Ride', 'amount_min' => -3500, 'amount_max' => -1200, 'category_name' => 'Transportation expenses', 'frequency' => 'weekly'], + ['description' => 'Lyft Ride', 'amount_min' => -2800, 'amount_max' => -1000, 'category_name' => 'Transportation expenses', 'frequency' => 'weekly'], + ['description' => 'Parking Garage', 'amount_min' => -2500, 'amount_max' => -800, 'category_name' => 'Parking', 'frequency' => 'weekly'], + ['description' => 'H&M Clothing', 'amount_min' => -8500, 'amount_max' => -3500, 'category_name' => 'Clothing and shoes', 'frequency' => 'monthly'], + ['description' => 'Nike Store', 'amount_min' => -15000, 'amount_max' => -6500, 'category_name' => 'Clothing and shoes', 'frequency' => 'quarterly'], + ['description' => 'AMC Movie Theater', 'amount_min' => -3500, 'amount_max' => -1500, 'category_name' => 'Theatre, music, cinema', 'frequency' => 'monthly'], + ['description' => 'Barnes & Noble Books', 'amount_min' => -4500, 'amount_max' => -1500, 'category_name' => 'Books, newspapers, magazines', 'frequency' => 'monthly'], + ['description' => 'Interest Payment', 'amount_min' => 250, 'amount_max' => 850, 'category_name' => 'Other incoming payments', 'frequency' => 'monthly'], + ['description' => 'Dividend - VTI ETF', 'amount_min' => 15000, 'amount_max' => 25000, 'category_name' => 'Other incoming payments', 'frequency' => 'quarterly'], + ['description' => 'Transfer to Savings', 'amount_min' => -50000, 'amount_max' => -25000, 'category_name' => 'Own account', 'frequency' => 'monthly'], + ['description' => 'Birthday Gift from Mom', 'amount_min' => 10000, 'amount_max' => 25000, 'category_name' => 'From account of relatives', 'frequency' => 'yearly'], + ['description' => 'Venmo from Friend', 'amount_min' => 2000, 'amount_max' => 8000, 'category_name' => 'Other personal transfers', 'frequency' => 'monthly'], + ]; + + /** + * Generate 12 months of realistic transactions. + * + * @return array + */ + public function getTransactions(): array + { + $transactions = []; + $endDate = Carbon::now(); + $startDate = Carbon::now()->subMonths(12); + + foreach (self::TRANSACTION_TEMPLATES as $template) { + $dates = $this->generateDatesForFrequency($template['frequency'], $startDate, $endDate); + + foreach ($dates as $date) { + $amount = $template['amount_min'] === $template['amount_max'] + ? $template['amount_min'] + : rand($template['amount_min'], $template['amount_max']); + + $transactions[] = [ + 'description' => $template['description'], + 'transaction_date' => $date->format('Y-m-d'), + 'amount' => $amount, + 'currency_code' => 'USD', + 'notes' => null, + 'notes_iv' => null, + 'source' => TransactionSource::ManuallyCreated, + 'category_name' => $template['category_name'], + ]; + } + } + + usort($transactions, fn ($a, $b) => strcmp($b['transaction_date'], $a['transaction_date'])); + + return $transactions; + } + + /** + * @return array + */ + private function generateDatesForFrequency(string $frequency, Carbon $startDate, Carbon $endDate): array + { + $dates = []; + $current = $startDate->copy(); + + switch ($frequency) { + case 'frequent': + while ($current->lte($endDate)) { + if (rand(1, 100) <= 40) { + $dates[] = $current->copy()->addHours(rand(8, 20)); + } + $current->addDays(rand(2, 4)); + } + break; + + case 'weekly': + while ($current->lte($endDate)) { + $dates[] = $current->copy()->addDays(rand(0, 2))->addHours(rand(8, 20)); + $current->addWeek(); + } + break; + + case 'biweekly': + while ($current->lte($endDate)) { + $dates[] = $current->copy()->addDays(rand(0, 3))->addHours(rand(8, 20)); + $current->addWeeks(2); + } + break; + + case 'monthly': + while ($current->lte($endDate)) { + $dayOfMonth = min($current->daysInMonth, rand(1, 28)); + $dates[] = $current->copy()->day($dayOfMonth)->addHours(rand(8, 20)); + $current->addMonth(); + } + break; + + case 'quarterly': + while ($current->lte($endDate)) { + $dates[] = $current->copy()->addDays(rand(0, 14))->addHours(rand(8, 20)); + $current->addMonths(3); + } + break; + + case 'yearly': + $dates[] = $startDate->copy()->addMonths(rand(0, 11))->addDays(rand(0, 28)); + break; + } + + return array_filter($dates, fn ($date) => $date->lte($endDate) && $date->gte($startDate)); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 612cdf67..cd051183 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -31,11 +31,13 @@ return Application::configure(basePath: dirname(__DIR__)) HandleAppearance::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, + \App\Http\Middleware\BlockDemoAccountActions::class.':auto', ]); $middleware->alias([ 'subscribed' => EnsureUserIsSubscribed::class, 'onboarded' => \App\Http\Middleware\EnsureOnboardingComplete::class, + 'block-demo' => \App\Http\Middleware\BlockDemoAccountActions::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/config/app.php b/config/app.php index 423eed59..2dbb23bb 100644 --- a/config/app.php +++ b/config/app.php @@ -123,4 +123,10 @@ return [ 'store' => env('APP_MAINTENANCE_STORE', 'database'), ], + 'demo' => [ + 'email' => env('DEMO_EMAIL', 'demo@whisper.money'), + 'password' => env('DEMO_PASSWORD', 'demo'), + 'encryption_key' => env('DEMO_ENCRYPTION_KEY', 'demo'), + ], + ]; diff --git a/database/seeders/data/banks.json b/database/seeders/data/banks.json index 40a57760..5f7ddb0e 100644 --- a/database/seeders/data/banks.json +++ b/database/seeders/data/banks.json @@ -9446,5 +9446,17 @@ { "name": "Česká spořitelna", "logo": "https://cdn-logos.gocardless.com/ais/CESKA_SPORITELNA_GIBACZPX.png" + }, + { + "name": "Indexa Capital", + "logo": "/images/banks/logos/indexa-capital.jpg" + }, + { + "name": "Binance", + "logo": "https://whisper.money/storage/banks/logos/t1h5rqi19dJTPl6ZadziPjNwm0lrcdTFBRzB3iCy.png" + }, + { + "name": "Bitpanda", + "logo": "https://whisper.money/storage/banks/logos/7Y6gl0gaFH1mStJMcUQ9VpgzX1kduyumm0dDhGlf.png" } ] diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx index 7a8cf6e7..a60d8e6e 100644 --- a/resources/js/components/delete-user.tsx +++ b/resources/js/components/delete-user.tsx @@ -1,6 +1,7 @@ import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import HeadingSmall from '@/components/heading-small'; import InputError from '@/components/input-error'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -13,12 +14,33 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Form } from '@inertiajs/react'; +import { type SharedData } from '@/types'; +import { Form, usePage } from '@inertiajs/react'; +import { InfoIcon } from 'lucide-react'; import { useRef } from 'react'; export default function DeleteUser() { + const { auth } = usePage().props; + const isDemoAccount = auth?.isDemoAccount ?? false; const passwordInput = useRef(null); + if (isDemoAccount) { + return ( +
+ + + + + The demo account cannot be deleted. + + +
+ ); + } + return (
().props; + const isDemoAccount: boolean = auth?.isDemoAccount ?? false; const [password, setPassword] = useState(''); const [storagePreference, setStoragePreference] = useState< 'session' | 'persistent' @@ -128,6 +132,12 @@ export default function UnlockMessageDialog({ placeholder="Enter your encryption password" disabled={processing} /> + {isDemoAccount && demoEncryptionKey && ( +

+ Demo encryption password: + {demoEncryptionKey} +

+ )}
diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 266c349e..ca8f9d9e 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -21,6 +21,7 @@ import { type PropsWithChildren } from 'react'; const getNavItems = ( subscriptionsEnabled: boolean, + isDemoAccount: boolean, ): (NavItem | NavSectionHeader | NavDivider)[] => [ { type: 'nav-item', @@ -51,13 +52,17 @@ const getNavItems = ( type: 'section-header', title: 'Profile Settings', }, - { - type: 'nav-item', - title: 'User account', - href: editAccount(), - icon: null, - }, - ...(subscriptionsEnabled + ...(!isDemoAccount + ? [ + { + type: 'nav-item', + title: 'User account', + href: editAccount(), + icon: null, + }, + ] + : []), + ...(subscriptionsEnabled && !isDemoAccount ? [ { type: 'nav-item' as const, @@ -73,17 +78,22 @@ const getNavItems = ( href: editAppearance(), icon: null, }, - { type: 'divider' }, - { - type: 'nav-item', - title: 'Delete Account', - href: editDeleteAccount(), - icon: null, - }, + ...(!isDemoAccount + ? [ + { type: 'divider' as const }, + { + type: 'nav-item' as const, + title: 'Delete Account', + href: editDeleteAccount(), + icon: null, + }, + ] + : []), ]; export default function SettingsLayout({ children }: PropsWithChildren) { - const { subscriptionsEnabled } = usePage().props; + const { subscriptionsEnabled, auth } = usePage().props; + const isDemoAccount = auth?.isDemoAccount ?? false; // When server-side rendering, we only render the layout on the client... if (typeof window === 'undefined') { @@ -91,7 +101,7 @@ export default function SettingsLayout({ children }: PropsWithChildren) { } const currentPath = window.location.pathname; - const sidebarNavItems = getNavItems(subscriptionsEnabled); + const sidebarNavItems = getNavItems(subscriptionsEnabled, isDemoAccount); return (
diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index 09d64072..832be17d 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -10,8 +10,9 @@ import { clearKey } from '@/lib/key-storage'; import { register } from '@/routes'; import { store } from '@/routes/login'; import { request } from '@/routes/password'; -import { Form, Head } from '@inertiajs/react'; -import { useEffect } from 'react'; +import { type SharedData } from '@/types'; +import { Form, Head, usePage } from '@inertiajs/react'; +import { useEffect, useState } from 'react'; interface LoginProps { status?: string; @@ -24,9 +25,19 @@ export default function Login({ canResetPassword, canRegister, }: LoginProps) { + const { demoCredentials } = usePage().props; + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + useEffect(() => { clearKey(); - }, []); + + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('demo') === '1' && demoCredentials) { + setEmail(demoCredentials.email); + setPassword(demoCredentials.password); + } + }, [demoCredentials]); return ( setEmail(e.target.value)} />
@@ -80,6 +93,10 @@ export default function Login({ tabIndex={2} autoComplete="current-password" placeholder="Password" + value={password} + onChange={(e) => + setPassword(e.target.value) + } />
diff --git a/resources/js/pages/settings/billing.tsx b/resources/js/pages/settings/billing.tsx index 69ccc249..ac23aff3 100644 --- a/resources/js/pages/settings/billing.tsx +++ b/resources/js/pages/settings/billing.tsx @@ -1,14 +1,16 @@ import HeadingSmall from '@/components/heading-small'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; import { billing } from '@/routes/settings'; -import { type BreadcrumbItem } from '@/types'; -import { Head } from '@inertiajs/react'; +import { type BreadcrumbItem, type SharedData } from '@/types'; +import { Head, usePage } from '@inertiajs/react'; import { CheckIcon, CreditCardIcon, InfinityIcon, + InfoIcon, ShieldCheckIcon, SparklesIcon, } from 'lucide-react'; @@ -46,6 +48,9 @@ const benefits = [ ]; export default function Billing() { + const { auth } = usePage().props; + const isDemoAccount = auth?.isDemoAccount ?? false; + return ( @@ -57,6 +62,16 @@ export default function Billing() { description="You're enjoying all the benefits of Whisper Money Pro" /> + {isDemoAccount && ( + + + + Billing management is not available on the demo + account. + + + )} +
{benefits.map((benefit) => (
diff --git a/resources/js/pages/settings/password.tsx b/resources/js/pages/settings/password.tsx index 4d1a4565..893f57dd 100644 --- a/resources/js/pages/settings/password.tsx +++ b/resources/js/pages/settings/password.tsx @@ -2,16 +2,18 @@ import PasswordController from '@/actions/App/Http/Controllers/Settings/Password import InputError from '@/components/input-error'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; -import { type BreadcrumbItem } from '@/types'; +import { type BreadcrumbItem, type SharedData } from '@/types'; import { Transition } from '@headlessui/react'; -import { Form, Head } from '@inertiajs/react'; +import { Form, Head, usePage } from '@inertiajs/react'; import { useRef } from 'react'; import HeadingSmall from '@/components/heading-small'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { edit } from '@/routes/user-password'; +import { InfoIcon } from 'lucide-react'; const breadcrumbs: BreadcrumbItem[] = [ { @@ -21,6 +23,8 @@ const breadcrumbs: BreadcrumbItem[] = [ ]; export default function Password() { + const { auth } = usePage().props; + const isDemoAccount = auth?.isDemoAccount ?? false; const passwordInput = useRef(null); const currentPasswordInput = useRef(null); @@ -35,6 +39,16 @@ export default function Password() { description="Ensure your account is using a long, random password to stay secure" /> + {isDemoAccount && ( + + + + Password changes are disabled on the demo + account. + + + )} +
- )} -
- + {!isDemoAccount && ( +
+
+ {({ processing }) => ( + + )} +
+
+ )} ) : (
@@ -89,33 +105,37 @@ export default function TwoFactor({ application on your phone.

-
- {hasSetupData ? ( - - ) : ( -
- setShowSetupModal(true) - } - > - {({ processing }) => ( - - )} -
- )} -
+ {!isDemoAccount && ( +
+ {hasSetupData ? ( + + ) : ( +
+ setShowSetupModal(true) + } + > + {({ processing }) => ( + + )} +
+ )} +
+ )}
)} diff --git a/resources/js/pages/welcome.tsx b/resources/js/pages/welcome.tsx index 5ad8ab51..a3914c1a 100644 --- a/resources/js/pages/welcome.tsx +++ b/resources/js/pages/welcome.tsx @@ -276,12 +276,26 @@ export default function Welcome({ while keeping your information completely secure.

-
- - - +
+
+ + + + + + +

Your data is yours alone. Sign up to get started. diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index d2b1b2b0..c8fa9066 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -8,6 +8,7 @@ import { UUID } from './uuid'; export interface Auth { user: User; hasProPlan: boolean; + isDemoAccount: boolean; } export interface BreadcrumbItem { diff --git a/routes/console.php b/routes/console.php index 22b26c3d..b40ba353 100644 --- a/routes/console.php +++ b/routes/console.php @@ -3,3 +3,4 @@ use Illuminate\Support\Facades\Schedule; Schedule::command('horizon:snapshot')->everyFiveMinutes(); +Schedule::command('demo:reset')->dailyAt('00:00'); diff --git a/routes/settings.php b/routes/settings.php index df26db71..ac9f6124 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -16,13 +16,15 @@ Route::middleware('auth')->group(function () { Route::get('settings/account', [ProfileController::class, 'account'])->name('account.edit'); Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update'); - Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); + Route::delete('settings/profile', [ProfileController::class, 'destroy']) + ->middleware('block-demo') + ->name('profile.destroy'); Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::get('settings/password', [PasswordController::class, 'edit'])->name('user-password.edit'); Route::put('settings/password', [PasswordController::class, 'update']) - ->middleware('throttle:6,1') + ->middleware(['throttle:6,1', 'block-demo']) ->name('user-password.update'); Route::get('settings/accounts', [AccountController::class, 'index'])->name('accounts.index'); diff --git a/tests/Feature/Console/ResetDemoAccountCommandTest.php b/tests/Feature/Console/ResetDemoAccountCommandTest.php new file mode 100644 index 00000000..96407b4e --- /dev/null +++ b/tests/Feature/Console/ResetDemoAccountCommandTest.php @@ -0,0 +1,228 @@ + [ + 'email' => 'demo@whisper.money', + 'password' => 'demo', + 'encryption_key' => 'demo', + ]]); +}); + +test('demo:reset creates demo user if not exists', function () { + $this->artisan('demo:reset') + ->assertSuccessful(); + + expect(User::where('email', 'demo@whisper.money')->exists())->toBeTrue(); +}); + +test('demo:reset creates 5 accounts', function () { + $this->artisan('demo:reset') + ->assertSuccessful(); + + $user = User::where('email', 'demo@whisper.money')->first(); + expect($user->accounts()->count())->toBe(6); + + $types = $user->accounts->pluck('type')->toArray(); + expect($types)->toContain(AccountType::Checking); + expect($types)->toContain(AccountType::Savings); + expect($types)->toContain(AccountType::Retirement); + expect($types)->toContain(AccountType::Investment); +}); + +test('demo:reset creates 12 months of transactions per account', function () { + $this->artisan('demo:reset') + ->assertSuccessful(); + + $user = User::where('email', 'demo@whisper.money')->first(); + + foreach ($user->accounts as $account) { + if ($account->type === AccountType::Investment || $account->type === AccountType::Retirement) { + continue; + } + + expect($account->transactions()->count())->toBeGreaterThan(100); + } + + expect($user->transactions()->count())->toBeGreaterThan(2000); +}); + +test('demo:reset creates 3 labels', function () { + $this->artisan('demo:reset') + ->assertSuccessful(); + + $user = User::where('email', 'demo@whisper.money')->first(); + expect($user->labels()->count())->toBe(3); +}); + +test('demo:reset creates 5 automation rules', function () { + $this->artisan('demo:reset') + ->assertSuccessful(); + + $user = User::where('email', 'demo@whisper.money')->first(); + expect($user->automationRules()->count())->toBe(5); +}); + +test('demo:reset creates default categories', function () { + $this->artisan('demo:reset') + ->assertSuccessful(); + + $user = User::where('email', 'demo@whisper.money')->first(); + expect($user->categories()->count())->toBe(63); +}); + +test('demo:reset deletes existing data before recreating', function () { + $this->artisan('demo:reset')->assertSuccessful(); + + $user = User::where('email', 'demo@whisper.money')->first(); + $originalAccountIds = $user->accounts->pluck('id')->toArray(); + $originalTransactionCount = $user->transactions()->count(); + + $this->artisan('demo:reset')->assertSuccessful(); + + $user->refresh(); + $newAccountIds = $user->accounts->pluck('id')->toArray(); + + expect(array_intersect($originalAccountIds, $newAccountIds))->toBeEmpty(); + + expect($user->accounts()->count())->toBe(6); + expect($user->transactions()->count())->toBeGreaterThan(2000); +}); + +test('demo:reset fails if demo email is not configured', function () { + config(['app.demo.email' => null]); + + $this->artisan('demo:reset') + ->assertFailed(); +}); + +test('demo:reset assigns labels to transactions based on percentage', function () { + $this->artisan('demo:reset') + ->assertSuccessful(); + + $user = User::where('email', 'demo@whisper.money')->first(); + + $transactionsWithLabels = $user->transactions()->whereHas('labels')->count(); + expect($transactionsWithLabels)->toBeGreaterThan(0); +}); + +test('demo:reset creates an active subscription', function () { + $this->artisan('demo:reset') + ->assertSuccessful(); + + $user = User::where('email', 'demo@whisper.money')->first(); + + expect($user->subscriptions()->count())->toBe(1); + expect($user->subscribed('default'))->toBeTrue(); + expect($user->hasProPlan())->toBeTrue(); +}); + +test('demo:reset creates 12 months of balance history for all accounts', function () { + $this->artisan('demo:reset') + ->assertSuccessful(); + + $user = User::where('email', 'demo@whisper.money')->first(); + + foreach ($user->accounts as $account) { + expect($account->balances()->count())->toBe(13); + expect($account->balances()->first()->balance)->toBeGreaterThan(0); + + $balances = $account->balances()->pluck('balance')->toArray(); + foreach ($balances as $balance) { + $cents = $balance % 100; + expect($cents)->toBeGreaterThanOrEqual(0); + } + } +}); + +test('demo:reset creates balance history with at least 5% growth over the year', function () { + $this->artisan('demo:reset') + ->assertSuccessful(); + + $user = User::where('email', 'demo@whisper.money')->first(); + $minGrowthPercentage = 0.05; + + foreach ($user->accounts as $account) { + $balances = $account->balances() + ->orderBy('balance_date', 'desc') + ->pluck('balance') + ->toArray(); + + $currentBalance = $balances[0]; + $oldestBalance = $balances[12]; + + $growth = ($currentBalance - $oldestBalance) / $oldestBalance; + expect($growth)->toBeGreaterThanOrEqual($minGrowthPercentage); + } +}); + +test('demo:reset assigns categories to all transactions', function () { + $this->artisan('demo:reset') + ->assertSuccessful(); + + $user = User::where('email', 'demo@whisper.money')->first(); + + $transactionsWithoutCategory = $user->transactions()->whereNull('category_id')->count(); + expect($transactionsWithoutCategory)->toBe(0); +}); + +test('demo:reset assigns accounts to all transactions', function () { + $this->artisan('demo:reset') + ->assertSuccessful(); + + $user = User::where('email', 'demo@whisper.money')->first(); + + $transactionsWithoutAccount = $user->transactions()->whereNull('account_id')->count(); + expect($transactionsWithoutAccount)->toBe(0); +}); + +test('demo:reset creates encrypted message that can be decrypted', function () { + $this->artisan('demo:reset')->assertSuccessful(); + + $user = User::where('email', 'demo@whisper.money')->first(); + $encryptedMessage = $user->encryptedMessage; + + expect($encryptedMessage)->not->toBeNull(); + expect($encryptedMessage->encrypted_content)->not->toBeEmpty(); + expect($encryptedMessage->iv)->not->toBeEmpty(); + + $service = new \App\Services\Demo\DemoEncryptionService; + $key = $service->deriveKey('demo', $user->encryption_salt); + + $decrypted = $service->decrypt($encryptedMessage->encrypted_content, $key, $encryptedMessage->iv); + expect($decrypted)->toBe('Hello, world'); +}); + +test('demo:reset encrypts account names correctly', function () { + $this->artisan('demo:reset')->assertSuccessful(); + + $user = User::where('email', 'demo@whisper.money')->first(); + $service = new \App\Services\Demo\DemoEncryptionService; + $key = $service->deriveKey('demo', $user->encryption_salt); + + $account = $user->accounts()->first(); + $decryptedName = $service->decrypt($account->name, $key, $account->name_iv); + + expect($decryptedName)->toBeIn([ + 'Primary Checking', + 'Joint Checking', + 'Emergency Fund', + '401(k) Retirement', + 'Brokerage Account', + ]); +}); + +test('demo:reset encrypts transaction descriptions correctly', function () { + $this->artisan('demo:reset')->assertSuccessful(); + + $user = User::where('email', 'demo@whisper.money')->first(); + $service = new \App\Services\Demo\DemoEncryptionService; + $key = $service->deriveKey('demo', $user->encryption_salt); + + $transaction = $user->transactions()->first(); + $decryptedDescription = $service->decrypt($transaction->description, $key, $transaction->description_iv); + + expect($decryptedDescription)->not->toBeEmpty(); +}); diff --git a/tests/Feature/DemoAccountRestrictionsTest.php b/tests/Feature/DemoAccountRestrictionsTest.php new file mode 100644 index 00000000..748c71e8 --- /dev/null +++ b/tests/Feature/DemoAccountRestrictionsTest.php @@ -0,0 +1,89 @@ + [ + 'email' => 'demo@whisper.money', + 'password' => 'demo', + 'encryption_key' => 'demo', + ]]); + + $this->artisan('demo:reset')->assertSuccessful(); + $this->demoUser = User::where('email', 'demo@whisper.money')->first(); +}); + +test('demo account cannot change password', function () { + $this->actingAs($this->demoUser); + + $this->put(route('user-password.update'), [ + 'current_password' => 'demo', + 'password' => 'newpassword123', + 'password_confirmation' => 'newpassword123', + ])->assertSessionHasErrors('demo'); +}); + +test('demo account cannot delete account', function () { + $this->actingAs($this->demoUser); + + $this->delete(route('profile.destroy'), [ + 'password' => 'demo', + ])->assertSessionHasErrors('demo'); + + expect(User::where('email', 'demo@whisper.money')->exists())->toBeTrue(); +}); + +test('demo account cannot enable two-factor authentication', function () { + $this->actingAs($this->demoUser); + + $this->post('/user/two-factor-authentication') + ->assertRedirect() + ->assertSessionHasErrors('demo'); +}); + +test('demo account cannot access billing portal', function () { + config(['subscriptions.enabled' => true]); + + $this->actingAs($this->demoUser); + + $this->get(route('settings.billing.portal')) + ->assertRedirect(route('settings.billing')) + ->assertSessionHasErrors('demo'); +}); + +test('regular user can change password', function () { + $user = User::factory()->create([ + 'password' => 'oldpassword123', + ]); + + $this->actingAs($user); + + $this->put(route('user-password.update'), [ + 'current_password' => 'oldpassword123', + 'password' => 'newpassword123', + 'password_confirmation' => 'newpassword123', + ])->assertSessionHasNoErrors(); +}); + +test('regular user can delete account', function () { + $user = User::factory()->create([ + 'password' => 'password123', + ]); + + $this->actingAs($user); + + $this->delete(route('profile.destroy'), [ + 'password' => 'password123', + ])->assertRedirect('/'); + + expect(User::where('id', $user->id)->exists())->toBeFalse(); +}); + +test('isDemoAccount returns true for demo user', function () { + expect($this->demoUser->isDemoAccount())->toBeTrue(); +}); + +test('isDemoAccount returns false for regular user', function () { + $user = User::factory()->create(); + expect($user->isDemoAccount())->toBeFalse(); +}); diff --git a/tests/Unit/DemoEncryptionServiceTest.php b/tests/Unit/DemoEncryptionServiceTest.php new file mode 100644 index 00000000..61645ff7 --- /dev/null +++ b/tests/Unit/DemoEncryptionServiceTest.php @@ -0,0 +1,85 @@ +generateSalt('demo'); + $salt2 = $service->generateSalt('demo'); + + expect($salt1)->toBe($salt2); + expect(base64_decode($salt1))->toHaveLength(16); +}); + +test('generates different salts for different seeds', function () { + $service = new DemoEncryptionService; + + $salt1 = $service->generateSalt('demo'); + $salt2 = $service->generateSalt('other'); + + expect($salt1)->not->toBe($salt2); +}); + +test('derives key from password and salt', function () { + $service = new DemoEncryptionService; + + $salt = $service->generateSalt('demo'); + $key = $service->deriveKey('demo', $salt); + + expect(strlen($key))->toBe(32); +}); + +test('derives same key for same password and salt', function () { + $service = new DemoEncryptionService; + + $salt = $service->generateSalt('demo'); + $key1 = $service->deriveKey('demo', $salt); + $key2 = $service->deriveKey('demo', $salt); + + expect($key1)->toBe($key2); +}); + +test('encrypts and decrypts text correctly', function () { + $service = new DemoEncryptionService; + + $salt = $service->generateSalt('demo'); + $key = $service->deriveKey('demo', $salt); + + $plaintext = 'Hello, world'; + $encrypted = $service->encrypt($plaintext, $key); + + expect($encrypted)->toHaveKeys(['encrypted', 'iv']); + expect($encrypted['encrypted'])->not->toBe($plaintext); + + $decrypted = $service->decrypt($encrypted['encrypted'], $key, $encrypted['iv']); + + expect($decrypted)->toBe($plaintext); +}); + +test('deterministic iv produces same encrypted output', function () { + $service = new DemoEncryptionService; + + $salt = $service->generateSalt('demo'); + $key = $service->deriveKey('demo', $salt); + + $plaintext = 'Hello, world'; + $encrypted1 = $service->encrypt($plaintext, $key, 'seed1'); + $encrypted2 = $service->encrypt($plaintext, $key, 'seed1'); + + expect($encrypted1['encrypted'])->toBe($encrypted2['encrypted']); + expect($encrypted1['iv'])->toBe($encrypted2['iv']); +}); + +test('different iv seeds produce different encrypted output', function () { + $service = new DemoEncryptionService; + + $salt = $service->generateSalt('demo'); + $key = $service->deriveKey('demo', $salt); + + $plaintext = 'Hello, world'; + $encrypted1 = $service->encrypt($plaintext, $key, 'seed1'); + $encrypted2 = $service->encrypt($plaintext, $key, 'seed2'); + + expect($encrypted1['encrypted'])->not->toBe($encrypted2['encrypted']); +});