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
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.
+
+
+ )}
+
-
-
-
-
+
+
+
+
+
+
+
+
+
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']);
+});