Demo Account Experience (#51)

## Summary

<img width="1220" height="1001" alt="whispermoney test_"
src="https://github.com/user-attachments/assets/c35751d5-385b-449c-81d6-14b5b6577ff2"
/>

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.
This commit is contained in:
Víctor Falcón 2026-01-07 10:58:14 +01:00 committed by GitHub
parent cb1d6a230f
commit 9bd1fcea37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1448 additions and 82 deletions

View File

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

View File

@ -0,0 +1,394 @@
<?php
namespace App\Console\Commands;
use App\Actions\CreateDefaultCategories;
use App\Enums\AccountType;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Category;
use App\Models\EncryptedMessage;
use App\Models\Label;
use App\Models\User;
use App\Services\Demo\DemoAutomationRulesProvider;
use App\Services\Demo\DemoEncryptionService;
use App\Services\Demo\DemoLabelsProvider;
use App\Services\Demo\DemoTransactionsProvider;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
class ResetDemoAccountCommand extends Command
{
protected $signature = 'demo:reset';
protected $description = 'Reset the demo account with fresh data';
private const MIN_BALANCE_GROWTH_PERCENTAGE = 0.05;
private string $encryptionKey;
public function __construct(
private DemoTransactionsProvider $transactionsProvider,
private DemoLabelsProvider $labelsProvider,
private DemoAutomationRulesProvider $rulesProvider,
private DemoEncryptionService $encryptionService,
) {
parent::__construct();
}
public function handle(): int
{
$demoEmail = config('app.demo.email');
$demoPassword = config('app.demo.password');
$demoEncryptionKey = config('app.demo.encryption_key');
if (! $demoEmail || ! $demoPassword) {
$this->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<int, array{label: Label, assignment_percentage: int}>
*/
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<int, array{label: Label, assignment_percentage: int}> $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<string, Category> $categories
* @param array<int, array{label: Label, assignment_percentage: int}> $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<int, array{label: Label, assignment_percentage: int}> $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');
}
}

View File

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

View File

@ -0,0 +1,48 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class BlockDemoAccountActions
{
private array $fortifyBlockedRoutes = [
'user/two-factor-authentication',
'user/confirmed-two-factor-authentication',
'user/two-factor-recovery-codes',
];
private array $blockedMethods = ['POST', 'PUT', 'PATCH', 'DELETE'];
public function handle(Request $request, Closure $next, ?string $mode = null): Response
{
if (! $request->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);
}
}

View File

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

View File

@ -112,4 +112,9 @@ class User extends Authenticatable
return $this->subscribed('default');
}
public function isDemoAccount(): bool
{
return $this->email === config('app.demo.email');
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Services\Demo;
class DemoAutomationRulesProvider
{
/**
* Get demo automation rules.
* Each rule references a category name that will be resolved during seeding.
*
* @return array<int, array{title: string, priority: int, rules_json: array<string, mixed>, 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,
],
];
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Services\Demo;
class DemoEncryptionService
{
private const PBKDF2_ITERATIONS = 100000;
private const SALT_LENGTH = 16;
private const IV_LENGTH = 12;
private const KEY_LENGTH = 32;
/**
* Generate a deterministic salt from a seed string.
*/
public function generateSalt(string $seed = 'demo'): string
{
$salt = hash('sha256', $seed, true);
return base64_encode(substr($salt, 0, self::SALT_LENGTH));
}
/**
* Derive an AES key from password and salt using PBKDF2.
*/
public function deriveKey(string $password, string $saltBase64): string
{
$salt = base64_decode($saltBase64);
return hash_pbkdf2(
'sha256',
$password,
$salt,
self::PBKDF2_ITERATIONS,
self::KEY_LENGTH,
true
);
}
/**
* Encrypt plaintext using AES-256-GCM.
*
* @return array{encrypted: string, iv: string}
*/
public function encrypt(string $plaintext, string $key, ?string $deterministicIvSeed = null): array
{
if ($deterministicIvSeed !== null) {
$iv = substr(hash('sha256', $deterministicIvSeed, true), 0, self::IV_LENGTH);
} else {
$iv = random_bytes(self::IV_LENGTH);
}
$tag = '';
$encrypted = openssl_encrypt(
$plaintext,
'aes-256-gcm',
$key,
OPENSSL_RAW_DATA,
$iv,
$tag,
'',
16
);
$encryptedWithTag = $encrypted.$tag;
return [
'encrypted' => 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;
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Services\Demo;
class DemoLabelsProvider
{
/**
* Get demo labels with assignment percentages.
*
* @return array<int, array{name: string, color: string, assignment_percentage: int}>
*/
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,
],
];
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Services\Demo;
use App\Enums\TransactionSource;
use Carbon\Carbon;
class DemoTransactionsProvider
{
/**
* @var array<int, array{description: string, amount_min: int, amount_max: int, category_name: string, frequency: string}>
*/
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<int, array{description: string, transaction_date: string, amount: int, currency_code: string, notes: string|null, notes_iv: string|null, source: TransactionSource, category_name: string}>
*/
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<int, Carbon>
*/
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));
}
}

View File

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

View File

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

View File

@ -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"
}
]

View File

@ -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<SharedData>().props;
const isDemoAccount = auth?.isDemoAccount ?? false;
const passwordInput = useRef<HTMLInputElement>(null);
if (isDemoAccount) {
return (
<div className="space-y-6">
<HeadingSmall
title="Delete account"
description="Delete your account and all of its resources"
/>
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription>
The demo account cannot be deleted.
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="space-y-6">
<HeadingSmall

View File

@ -29,6 +29,8 @@ import {
importKey,
} from '@/lib/crypto';
import { getStoredKey, storeKey } from '@/lib/key-storage';
import { type SharedData } from '@/types';
import { usePage } from '@inertiajs/react';
interface UnlockMessageDialogProps {
open: boolean;
@ -48,6 +50,8 @@ export default function UnlockMessageDialog({
salt,
}: UnlockMessageDialogProps) {
const { refreshKeyState } = useEncryptionKey();
const { demoEncryptionKey, auth } = usePage<SharedData>().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 && (
<p className="text-sm text-red-600 dark:text-red-400">
<span>Demo encryption password:</span>
<code>{demoEncryptionKey}</code>
</p>
)}
</div>
<div className="grid gap-2">

View File

@ -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<SharedData>().props;
const { subscriptionsEnabled, auth } = usePage<SharedData>().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 (
<div className="px-4 py-6">

View File

@ -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<SharedData>().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 (
<AuthLayout
@ -55,6 +66,8 @@ export default function Login({
tabIndex={1}
autoComplete="email"
placeholder="email@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<InputError message={errors.email} />
</div>
@ -80,6 +93,10 @@ export default function Login({
tabIndex={2}
autoComplete="current-password"
placeholder="Password"
value={password}
onChange={(e) =>
setPassword(e.target.value)
}
/>
<InputError message={errors.password} />
</div>

View File

@ -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<SharedData>().props;
const isDemoAccount = auth?.isDemoAccount ?? false;
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Manage Plan" />
@ -57,6 +62,16 @@ export default function Billing() {
description="You're enjoying all the benefits of Whisper Money Pro"
/>
{isDemoAccount && (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription>
Billing management is not available on the demo
account.
</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
{benefits.map((benefit) => (
<div
@ -90,12 +105,14 @@ export default function Billing() {
Manage your subscription, update payment methods, or
view invoices through the Stripe billing portal.
</p>
<a href={billing.portal.url()}>
<Button className="mt-4">
<CreditCardIcon className="size-4" />
Manage Subscription
</Button>
</a>
{!isDemoAccount && (
<a href={billing.portal.url()}>
<Button className="mt-4">
<CreditCardIcon className="size-4" />
Manage Subscription
</Button>
</a>
)}
</div>
</div>
</SettingsLayout>

View File

@ -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<SharedData>().props;
const isDemoAccount = auth?.isDemoAccount ?? false;
const passwordInput = useRef<HTMLInputElement>(null);
const currentPasswordInput = useRef<HTMLInputElement>(null);
@ -35,6 +39,16 @@ export default function Password() {
description="Ensure your account is using a long, random password to stay secure"
/>
{isDemoAccount && (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription>
Password changes are disabled on the demo
account.
</AlertDescription>
</Alert>
)}
<Form
{...PasswordController.update.form()}
options={{
@ -118,7 +132,7 @@ export default function Password() {
<div className="flex items-center gap-4">
<Button
disabled={processing}
disabled={processing || isDemoAccount}
data-test="update-password-button"
>
Save password

View File

@ -1,15 +1,16 @@
import HeadingSmall from '@/components/heading-small';
import TwoFactorRecoveryCodes from '@/components/two-factor-recovery-codes';
import TwoFactorSetupModal from '@/components/two-factor-setup-modal';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { disable, enable, show } from '@/routes/two-factor';
import { type BreadcrumbItem } from '@/types';
import { Form, Head } from '@inertiajs/react';
import { ShieldBan, ShieldCheck } from 'lucide-react';
import { type BreadcrumbItem, type SharedData } from '@/types';
import { Form, Head, usePage } from '@inertiajs/react';
import { InfoIcon, ShieldBan, ShieldCheck } from 'lucide-react';
import { useState } from 'react';
interface TwoFactorProps {
@ -28,6 +29,8 @@ export default function TwoFactor({
requiresConfirmation = false,
twoFactorEnabled = false,
}: TwoFactorProps) {
const { auth } = usePage<SharedData>().props;
const isDemoAccount = auth?.isDemoAccount ?? false;
const {
qrCodeSvg,
hasSetupData,
@ -49,6 +52,17 @@ export default function TwoFactor({
title="Two-Factor Authentication"
description="Manage your two-factor authentication settings"
/>
{isDemoAccount && (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription>
Two-factor authentication settings cannot be
changed on the demo account.
</AlertDescription>
</Alert>
)}
{twoFactorEnabled ? (
<div className="flex flex-col items-start justify-start space-y-4">
<Badge variant="default">Enabled</Badge>
@ -65,19 +79,21 @@ export default function TwoFactor({
errors={errors}
/>
<div className="relative inline">
<Form {...disable.form()}>
{({ processing }) => (
<Button
variant="destructive"
type="submit"
disabled={processing}
>
<ShieldBan /> Disable 2FA
</Button>
)}
</Form>
</div>
{!isDemoAccount && (
<div className="relative inline">
<Form {...disable.form()}>
{({ processing }) => (
<Button
variant="destructive"
type="submit"
disabled={processing}
>
<ShieldBan /> Disable 2FA
</Button>
)}
</Form>
</div>
)}
</div>
) : (
<div className="flex flex-col items-start justify-start space-y-4">
@ -89,33 +105,37 @@ export default function TwoFactor({
application on your phone.
</p>
<div>
{hasSetupData ? (
<Button
onClick={() => setShowSetupModal(true)}
>
<ShieldCheck />
Continue Setup
</Button>
) : (
<Form
{...enable.form()}
onSuccess={() =>
setShowSetupModal(true)
}
>
{({ processing }) => (
<Button
type="submit"
disabled={processing}
>
<ShieldCheck />
Enable 2FA
</Button>
)}
</Form>
)}
</div>
{!isDemoAccount && (
<div>
{hasSetupData ? (
<Button
onClick={() =>
setShowSetupModal(true)
}
>
<ShieldCheck />
Continue Setup
</Button>
) : (
<Form
{...enable.form()}
onSuccess={() =>
setShowSetupModal(true)
}
>
{({ processing }) => (
<Button
type="submit"
disabled={processing}
>
<ShieldCheck />
Enable 2FA
</Button>
)}
</Form>
)}
</div>
)}
</div>
)}

View File

@ -276,12 +276,26 @@ export default function Welcome({
while keeping your information completely
secure.
</p>
<div className="flex w-full max-w-xs flex-col gap-4">
<Link href="/register">
<Button className="text-shadow duration h-14 w-full cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md">
Get Started
</Button>
</Link>
<div className="flex w-full max-w-lg flex-col gap-4">
<div className="flex w-full flex-row gap-4">
<Link
href="/register"
className="w-full"
>
<Button className="text-shadow duration h-14 w-full cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md">
Get Started
</Button>
</Link>
<Link href="/login?demo=1">
<Button
variant={'secondary'}
size={'lg'}
className="h-14"
>
Check Demo
</Button>
</Link>
</div>
<p className="text-xs text-[#706f6c] dark:text-[#A1A09A]">
Your data is yours alone. Sign up to get
started.

View File

@ -8,6 +8,7 @@ import { UUID } from './uuid';
export interface Auth {
user: User;
hasProPlan: boolean;
isDemoAccount: boolean;
}
export interface BreadcrumbItem {

View File

@ -3,3 +3,4 @@
use Illuminate\Support\Facades\Schedule;
Schedule::command('horizon:snapshot')->everyFiveMinutes();
Schedule::command('demo:reset')->dailyAt('00:00');

View File

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

View File

@ -0,0 +1,228 @@
<?php
use App\Enums\AccountType;
use App\Models\User;
beforeEach(function () {
config(['app.demo' => [
'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();
});

View File

@ -0,0 +1,89 @@
<?php
use App\Models\User;
beforeEach(function () {
config(['app.demo' => [
'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();
});

View File

@ -0,0 +1,85 @@
<?php
use App\Services\Demo\DemoEncryptionService;
test('generates deterministic salt from seed', function () {
$service = new DemoEncryptionService;
$salt1 = $service->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']);
});