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:
parent
cb1d6a230f
commit
9bd1fcea37
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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', []),
|
||||
|
|
|
|||
|
|
@ -112,4 +112,9 @@ class User extends Authenticatable
|
|||
|
||||
return $this->subscribed('default');
|
||||
}
|
||||
|
||||
public function isDemoAccount(): bool
|
||||
{
|
||||
return $this->email === config('app.demo.email');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { UUID } from './uuid';
|
|||
export interface Auth {
|
||||
user: User;
|
||||
hasProPlan: boolean;
|
||||
isDemoAccount: boolean;
|
||||
}
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@
|
|||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command('horizon:snapshot')->everyFiveMinutes();
|
||||
Schedule::command('demo:reset')->dailyAt('00:00');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
Loading…
Reference in New Issue