User Onboarding Flow (#23)

## Summary

Implements a complete user onboarding flow that guides new users through
setting up their account after registration.

## Features

- **Step-by-step wizard UI** with progress indicator and smooth
animations
- **E2E Encryption setup** with password strength indicator and
explanation of how encryption works
- **Account creation** supporting all account types (checking, savings,
credit card, investment, pension)
- **Category customization** with explanation of category types
(expenses, income, transfer)
- **Smart rules explanation** covering why there is no AI
auto-categorization (privacy & E2EE)
- **Transaction/balance import** based on account type
- **Sync integration** to ensure data consistency with backend

## Flow Diagram

```mermaid
flowchart TD
    A[User Registration] --> B[Welcome]

    B --> E[Encryption Explained]
    
    E --> C{Has encryption key?}

    C -->|No|F[Encryption Setup]
    C -->|Yes|G{Has Existing Accounts?}
    G -->|Yes| H[Show Existing Accounts]
    G -->|No| I[Create First Account]
    
    H --> J[Continue]
    I --> K{Account Type?}
    
    K -->|Checking/Savings/Credit Card| L[Import Transactions]
    K -->|Investment/Pension| M[Import Balances]
    
    J --> N[Category Types Explanation]
    L --> N
    M --> N
    
    N --> O[Customize Categories]
    O --> P[Smart Rules Explanation]
    P --> Q[More Accounts?]
    
    Q -->|Add More| I
    Q -->|Finish| R[Complete]
    
    R --> S[Redirect to Dashboard]
    S --> T{Subscribed?}
    T -->|No| U[Subscribe Page]
    T -->|Yes| V[Dashboard]
```

## Technical Changes

### Backend
- Added `onboarded_at` field to users table with migration
- Created `EnsureOnboardingComplete` middleware for redirect logic
- Created `OnboardingController` with index and complete actions
- Custom `RegisterResponse` to redirect new users to onboarding
- Updated `AccountController::store` to return JSON for fetch requests

### Frontend
- `OnboardingLayout` - fullscreen layout with step progress
- `useOnboardingState` hook - manages step navigation and state
- 12 step components for each onboarding screen
- Backend sync after account creation and imports

### Tests
- Feature tests for onboarding middleware
- Updated existing tests to use `onboarded()` factory state
This commit is contained in:
Víctor Falcón 2025-12-12 13:06:08 +01:00 committed by GitHub
parent aaf1cdcace
commit c433088806
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 3414 additions and 304 deletions

View File

@ -74,4 +74,5 @@ STRIPE_WEBHOOK_SECRET=
# Subscriptions
SUBSCRIPTIONS_ENABLED=false
STRIPE_PRO_PRICE_ID=
STRIPE_PRO_MONTHLY_PRICE_ID=
STRIPE_PRO_YEARLY_PRICE_ID=

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers;
use App\Models\Bank;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class OnboardingController extends Controller
{
public function index(Request $request): Response
{
$user = $request->user();
$banks = Bank::query()
->whereNull('user_id')
->orWhere('user_id', $user->id)
->orderBy('name')
->get(['id', 'name', 'logo']);
$accounts = $user->accounts()
->with('bank:id,name,logo')
->get(['id', 'name', 'name_iv', 'type', 'currency_code', 'bank_id']);
return Inertia::render('onboarding/index', [
'banks' => $banks,
'accounts' => $accounts,
'hasEncryptionSetup' => $user->encryption_salt !== null,
]);
}
public function complete(Request $request): RedirectResponse
{
$request->user()->update([
'onboarded_at' => now(),
]);
return redirect()->route('dashboard');
}
}

View File

@ -13,7 +13,6 @@ class RobotsController extends Controller
$content = "User-agent: *\n";
$content .= "Disallow: /api/\n";
$content .= "Disallow: /dashboard\n";
$content .= "Disallow: /setup-encryption\n";
$content .= "Disallow: /transactions\n";
$content .= "Disallow: /settings\n";
$content .= "\n";

View File

@ -7,6 +7,7 @@ use App\Http\Requests\Settings\StoreAccountRequest;
use App\Http\Requests\Settings\UpdateAccountRequest;
use App\Models\Account;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -34,9 +35,13 @@ class AccountController extends Controller
/**
* Store a newly created account.
*/
public function store(StoreAccountRequest $request): RedirectResponse
public function store(StoreAccountRequest $request): RedirectResponse|JsonResponse
{
auth()->user()->accounts()->create($request->validated());
$account = auth()->user()->accounts()->create($request->validated());
if ($request->wantsJson()) {
return response()->json($account, 201);
}
return to_route('accounts.index');
}

View File

@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Models\AccountBalance;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@ -18,12 +20,51 @@ class SubscriptionController extends Controller
return redirect()->route('dashboard');
}
return Inertia::render('subscription/paywall');
return Inertia::render('subscription/paywall', [
'stats' => $this->getUserStats($user),
]);
}
/**
* @return array{accountsCount: int, transactionsCount: int, categoriesCount: int, automationRulesCount: int, balancesByCurrency: array<string, int>}
*/
private function getUserStats(User $user): array
{
$accounts = $user->accounts()->get();
$balancesByCurrency = [];
foreach ($accounts as $account) {
$latestBalance = AccountBalance::query()
->where('account_id', $account->id)
->orderBy('balance_date', 'desc')
->value('balance') ?? 0;
$currency = $account->currency_code;
if (! isset($balancesByCurrency[$currency])) {
$balancesByCurrency[$currency] = 0;
}
$balancesByCurrency[$currency] += $latestBalance;
}
return [
'accountsCount' => $accounts->count(),
'transactionsCount' => $user->transactions()->count(),
'categoriesCount' => $user->categories()->count(),
'automationRulesCount' => $user->automationRules()->count(),
'balancesByCurrency' => $balancesByCurrency,
];
}
public function checkout(Request $request): Checkout
{
$priceId = config('subscriptions.prices.pro_monthly');
$planKey = $request->query('plan', config('subscriptions.default_plan'));
$plan = config("subscriptions.plans.{$planKey}");
if (! $plan || ! $plan['stripe_price_id']) {
abort(400, 'Invalid plan selected');
}
$priceId = $plan['stripe_price_id'];
return $request->user()
->newSubscription('default', $priceId)

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureOnboardingComplete
{
/**
* Handle an incoming request.
*
* Redirects non-onboarded users to the onboarding flow.
* Redirects onboarded users away from the onboarding page.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return $next($request);
}
$isOnboardingRoute = $request->routeIs('onboarding') || $request->routeIs('onboarding.*');
if ($user->isOnboarded()) {
if ($isOnboardingRoute) {
return redirect()->route('dashboard');
}
return $next($request);
}
if (! $isOnboardingRoute) {
return redirect()->route('onboarding');
}
return $next($request);
}
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RedirectToEncryptionSetup
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if ($request->user() && $request->user()->encryption_salt === null) {
if (! $request->routeIs('setup-encryption') && ! $request->is('api/encryption/setup')) {
return redirect()->route('setup-encryption');
}
}
return $next($request);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Responses;
use Illuminate\Http\JsonResponse;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
use Symfony\Component\HttpFoundation\Response;
class RegisterResponse implements RegisterResponseContract
{
public function toResponse($request): Response
{
if ($request->wantsJson()) {
return new JsonResponse('', 201);
}
return redirect()->route('onboarding');
}
}

View File

@ -27,6 +27,7 @@ class User extends Authenticatable
'email',
'password',
'encryption_salt',
'onboarded_at',
];
/**
@ -52,9 +53,15 @@ class User extends Authenticatable
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime',
'onboarded_at' => 'datetime',
];
}
public function isOnboarded(): bool
{
return $this->onboarded_at !== null;
}
public function encryptedMessage(): HasOne
{
return $this->hasOne(EncryptedMessage::class);

View File

@ -2,7 +2,9 @@
namespace App\Providers;
use App\Http\Responses\RegisterResponse;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
class AppServiceProvider extends ServiceProvider
{
@ -11,7 +13,7 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->app->singleton(RegisterResponseContract::class, RegisterResponse::class);
}
/**

View File

@ -3,7 +3,6 @@
use App\Http\Middleware\EnsureUserIsSubscribed;
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\RedirectToEncryptionSetup;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@ -22,10 +21,10 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->trustProxies(
at: '*',
headers: Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO
headers: Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_HOST
| Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO
);
$middleware->web(append: [
@ -35,8 +34,8 @@ return Application::configure(basePath: dirname(__DIR__))
]);
$middleware->alias([
'redirect.encryption' => RedirectToEncryptionSetup::class,
'subscribed' => EnsureUserIsSubscribed::class,
'onboarded' => \App\Http\Middleware\EnsureOnboardingComplete::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

View File

@ -58,22 +58,22 @@ return [
'Priority support',
],
],
// 'yearly' => [
// 'name' => 'Pro Yearly',
// 'price' => 69,
// 'original_price' => 144,
// 'stripe_price_id' => env('STRIPE_PRO_YEARLY_PRICE_ID'),
// 'billing_period' => 'year',
// 'features' => [
// 'Unlimited accounts',
// 'Unlimited transactions',
// 'End-to-end encryption',
// 'Smart categorization',
// 'Automation rules',
// 'Visual insights & reports',
// 'Priority support',
// ],
// ],
'yearly' => [
'name' => 'Pro Yearly',
'price' => 48,
'original_price' => 144,
'stripe_price_id' => env('STRIPE_PRO_YEARLY_PRICE_ID'),
'billing_period' => 'year',
'features' => [
'Unlimited accounts',
'Unlimited transactions',
'End-to-end encryption',
'Smart categorization',
'Automation rules',
'Visual insights & reports',
'Priority support',
],
],
// 'lifetime' => [
// 'name' => 'Lifetime License',
// 'price' => 129,
@ -102,7 +102,7 @@ return [
|
*/
'default_plan' => 'yearly',
'default_plan' => 'monthly',
/*
|--------------------------------------------------------------------------

View File

@ -55,4 +55,25 @@ class UserFactory extends Factory
'two_factor_confirmed_at' => null,
]);
}
/**
* Indicate that the user has completed onboarding.
*/
public function onboarded(): static
{
return $this->state(fn (array $attributes) => [
'onboarded_at' => now(),
'encryption_salt' => 'test-salt',
]);
}
/**
* Indicate that the user has not completed onboarding.
*/
public function notOnboarded(): static
{
return $this->state(fn (array $attributes) => [
'onboarded_at' => null,
]);
}
}

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('onboarded_at')->nullable()->after('encryption_salt');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('onboarded_at');
});
}
};

View File

@ -34,6 +34,7 @@ interface AccountFormProps {
type: AccountType;
currencyCode: CurrencyCode;
};
forceAccountType?: AccountType;
onChange: (data: AccountFormData) => void;
}
@ -43,7 +44,11 @@ const initialCustomBankData: CustomBankData = {
logoPreview: null,
};
export function AccountForm({ initialValues, onChange }: AccountFormProps) {
export function AccountForm({
initialValues,
forceAccountType,
onChange,
}: AccountFormProps) {
const [displayName, setDisplayName] = useState(
initialValues?.displayName ?? '',
);
@ -51,7 +56,7 @@ export function AccountForm({ initialValues, onChange }: AccountFormProps) {
initialValues?.bank.id ?? null,
);
const [selectedType, setSelectedType] = useState<AccountType | null>(
initialValues?.type ?? null,
initialValues?.type ?? forceAccountType ?? null,
);
const [selectedCurrency, setSelectedCurrency] =
useState<CurrencyCode | null>(initialValues?.currencyCode ?? null);
@ -154,6 +159,7 @@ export function AccountForm({ initialValues, onChange }: AccountFormProps) {
<Select
name="type"
value={selectedType ?? undefined}
disabled={!!forceAccountType}
onValueChange={(value) =>
setSelectedType(value as AccountType)
}

View File

@ -0,0 +1,117 @@
import { StepButton } from '@/components/onboarding/step-button';
import { StepHeader } from '@/components/onboarding/step-header';
import {
Banknote,
Building2,
CreditCard,
LineChart,
PiggyBank,
TrendingUp,
Wallet,
} from 'lucide-react';
interface StepAccountTypesProps {
onContinue: () => void;
}
const accountTypes = [
{
type: 'checking',
name: 'Checking',
icon: Wallet,
description: 'Daily spending and transactions',
hasTransactions: true,
},
{
type: 'savings',
name: 'Savings',
icon: PiggyBank,
description: 'Save money for goals',
hasTransactions: true,
},
{
type: 'credit_card',
name: 'Credit Card',
icon: CreditCard,
description: 'Track credit card spending',
hasTransactions: true,
},
{
type: 'investment',
name: 'Investment',
icon: LineChart,
description: 'Stocks, ETFs, and portfolios',
hasTransactions: false,
},
{
type: 'retirement',
name: 'Retirement',
icon: TrendingUp,
description: '401k, IRA, pension funds',
hasTransactions: false,
},
{
type: 'loan',
name: 'Loan',
icon: Building2,
description: 'Mortgages and loans',
hasTransactions: false,
},
];
export function StepAccountTypes({ onContinue }: StepAccountTypesProps) {
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={Banknote}
iconContainerClassName="bg-gradient-to-br from-cyan-400 to-blue-500"
title="Account Types"
description="There are different account types. Some track transactions, others just track balance over time."
/>
<div className="grid w-full max-w-2xl gap-3 sm:grid-cols-2">
{accountTypes.map((account) => (
<div
key={account.type}
className="group relative flex flex-row items-center gap-2 overflow-hidden rounded-xl border bg-card p-3 transition-all hover:shadow-md sm:items-start sm:p-4"
>
<div className="flex w-full flex-col items-start gap-1 sm:gap-2">
<div className="flex w-full flex-row items-center justify-between gap-2 sm:items-start">
<div className="flex items-center gap-2">
<account.icon
className={`size-4 stroke-muted-foreground`}
/>
<h3 className="font-semibold">
{account.name}
</h3>
</div>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
account.hasTransactions
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}`}
>
{account.hasTransactions
? 'Transactions + Balance'
: 'Balance'}
</span>
</div>
<p className="text-sm text-muted-foreground">
{account.description}
</p>
</div>
</div>
))}
</div>
<div className="mt-8 w-full sm:w-auto">
<StepButton
text="Create Your First Account"
onClick={onContinue}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
interface StepButtonProps {
text: string;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
loadingText?: string;
type?: 'button' | 'submit';
'data-testid'?: string;
className?: string;
}
export function StepButton({
text,
onClick,
disabled = false,
loading = false,
loadingText,
type = 'button',
'data-testid': testId,
className = '',
}: StepButtonProps) {
return (
<Button
type={type}
size="lg"
onClick={onClick}
disabled={disabled || loading}
className={cn(
'group w-full gap-2 py-6 sm:w-auto sm:py-4',
className,
)}
data-testid={testId}
>
{loading ? (
<>
<Spinner />
{loadingText || text}
</>
) : (
<>{text}</>
)}
</Button>
);
}

View File

@ -0,0 +1,94 @@
import { StepButton } from '@/components/onboarding/step-button';
import { StepHeader } from '@/components/onboarding/step-header';
import { ArrowDownLeft, ArrowUpRight, Repeat, Tag } from 'lucide-react';
interface StepCategoryTypesProps {
onContinue: () => void;
}
const categoryTypes = [
{
type: 'expense',
name: 'Expense',
icon: ArrowUpRight,
description: 'Money going out',
examples: ['Food', 'Rent', 'Entertainment', 'Transport'],
color: 'from-red-500 to-rose-500',
bgColor: 'bg-red-50 dark:bg-red-900/20',
textColor: 'text-red-700 dark:text-red-400',
},
{
type: 'income',
name: 'Income',
icon: ArrowDownLeft,
description: 'Money coming in',
examples: ['Salary', 'Freelance', 'Investments', 'Refunds'],
color: 'from-emerald-500 to-green-500',
bgColor: 'bg-emerald-50 dark:bg-emerald-900/20',
textColor: 'text-emerald-700 dark:text-emerald-400',
},
{
type: 'transfer',
name: 'Transfer',
icon: Repeat,
description: 'Moving money between accounts',
examples: ['To savings', 'Credit card payment', 'Between banks'],
color: 'from-blue-500 to-cyan-500',
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
textColor: 'text-blue-700 dark:text-blue-400',
},
];
export function StepCategoryTypes({ onContinue }: StepCategoryTypesProps) {
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={Tag}
iconContainerClassName="bg-gradient-to-br from-violet-400 to-purple-500"
title="Understanding Categories"
description="Categories help you organize and understand your spending. Every transaction belongs to one of three types:"
/>
<div className="mb-8 grid w-full max-w-3xl gap-4 md:grid-cols-3">
{categoryTypes.map((category) => (
<div
key={category.type}
className="flex flex-col items-center rounded-xl border bg-card p-6 text-center"
>
<div
className={`mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br ${category.color}`}
>
<category.icon className="h-7 w-7 text-white" />
</div>
<h3 className="mb-2 text-xl font-semibold">
{category.name}
</h3>
<p className="mb-4 text-sm text-muted-foreground">
{category.description}
</p>
<div className="flex flex-wrap justify-center gap-1">
{category.examples.map((example) => (
<span
key={example}
className={`rounded-full px-2 py-0.5 text-xs ${category.bgColor} ${category.textColor}`}
>
{example}
</span>
))}
</div>
</div>
))}
</div>
<div className="mb-8 max-w-xl rounded-lg border border-violet-200 bg-violet-50 p-4 dark:border-violet-900/50 dark:bg-violet-900/20">
<p className="text-center text-sm text-violet-800 dark:text-violet-200">
<strong>Tip:</strong> We've already set up 50+ common
categories for you. You can customize them in the next step
or anytime from settings.
</p>
</div>
<StepButton text="Continue" onClick={onContinue} />
</div>
);
}

View File

@ -0,0 +1,80 @@
import { complete } from '@/actions/App/Http/Controllers/OnboardingController';
import { StepButton } from '@/components/onboarding/step-button';
import { dashboard } from '@/routes';
import { router } from '@inertiajs/react';
import { PartyPopper } from 'lucide-react';
import { useState } from 'react';
export function StepComplete() {
const [isRedirecting, setIsRedirecting] = useState(false);
const handleComplete = () => {
setIsRedirecting(true);
router.post(
complete.url(),
{},
{
onSuccess: () => {
router.visit(dashboard().url);
},
onError: () => {
setIsRedirecting(false);
},
},
);
};
return (
<div className="flex animate-in flex-col items-center text-center duration-500 fade-in slide-in-from-bottom-4">
<div className="relative mb-8">
<div className="flex h-24 w-24 animate-in items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 shadow-lg duration-700 spin-in-180 zoom-in">
<PartyPopper className="h-12 w-12 text-white" />
</div>
</div>
<h1 className="mb-2 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
You're All Set!
</h1>
<p className="mb-8 max-w-lg text-lg text-balance text-muted-foreground">
Your accounts are ready and your data is securely encrypted.
Welcome to Whisper Money!
</p>
<div className="mb-12 flex w-full max-w-md flex-col justify-center gap-4">
<div className="flex items-center justify-center gap-2 rounded-xl border bg-card p-2">
<div className="ml-1 text-2xl font-bold text-emerald-600 dark:text-emerald-400">
</div>
<p className="text-sm text-muted-foreground">
Encryption Set
</p>
</div>
<div className="flex items-center justify-center gap-2 rounded-xl border bg-card p-2">
<div className="ml-1 text-2xl font-bold text-emerald-600 dark:text-emerald-400">
</div>
<p className="text-sm text-muted-foreground">
Accounts Created
</p>
</div>
<div className="flex items-center justify-center gap-2 rounded-xl border bg-card p-2">
<div className="ml-1 text-2xl font-bold text-emerald-600 dark:text-emerald-400">
</div>
<p className="text-sm text-muted-foreground">
Data Imported
</p>
</div>
</div>
<StepButton
text="Go to Dashboard"
onClick={handleComplete}
loading={isRedirecting}
loadingText="Redirecting..."
/>
</div>
);
}

View File

@ -0,0 +1,324 @@
import { store } from '@/actions/App/Http/Controllers/Settings/AccountController';
import { store as storeBank } from '@/actions/App/Http/Controllers/Settings/BankController';
import {
AccountForm,
AccountFormData,
} from '@/components/accounts/account-form';
import { StepHeader } from '@/components/onboarding/step-header';
import { Button } from '@/components/ui/button';
import { CreatedAccount } from '@/hooks/use-onboarding-state';
import { encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { type AccountType, formatAccountType } from '@/types/account';
import { AlertCircle, CheckCircle2, CreditCard } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { StepButton } from './step-button';
interface ExistingAccount {
id: string;
name: string;
name_iv: string;
type: string;
currency_code: string;
bank_id: string;
bank?: {
id: string;
name: string;
logo: string | null;
};
}
interface StepCreateAccountProps {
banks: { id: string; name: string; logo: string | null }[];
isFirstAccount: boolean;
existingAccounts?: ExistingAccount[];
onAccountCreated: (account: CreatedAccount) => void;
onSkip?: () => void;
}
export function StepCreateAccount({
isFirstAccount,
existingAccounts = [],
onAccountCreated,
onSkip,
}: StepCreateAccountProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const formDataRef = useRef<AccountFormData>({
displayName: '',
bankId: null,
type: isFirstAccount ? 'checking' : null,
currencyCode: null,
customBank: null,
});
const handleFormChange = useCallback((data: AccountFormData) => {
formDataRef.current = data;
}, []);
async function createBankAndGetId(): Promise<string | null> {
const customBank = formDataRef.current.customBank;
if (!customBank) return null;
const formData = new FormData();
formData.append('name', customBank.name);
if (customBank.logo) {
formData.append('logo', customBank.logo);
}
const response = await fetch(storeBank.url(), {
method: 'POST',
body: formData,
headers: {
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
),
Accept: 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to create bank');
}
const data = await response.json();
return data.id;
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
const { displayName, bankId, type, currencyCode, customBank } =
formDataRef.current;
const keyString = getStoredKey();
if (!keyString) {
setError(
'Encryption key not available. Please go back and set up encryption.',
);
return;
}
if (!displayName.trim()) {
setError('Please enter an account name.');
return;
}
if (!type || !currencyCode) {
setError('Please fill in all required fields.');
return;
}
if (isFirstAccount && type !== 'checking') {
setError('Your first account must be a checking account.');
return;
}
setIsSubmitting(true);
try {
let finalBankId: string;
if (customBank) {
if (!customBank.name.trim()) {
setError('Please enter a bank name.');
setIsSubmitting(false);
return;
}
const createdBankId = await createBankAndGetId();
if (!createdBankId) {
throw new Error('Failed to create bank');
}
finalBankId = createdBankId;
} else {
if (!bankId) {
setError('Please select a bank.');
setIsSubmitting(false);
return;
}
finalBankId = String(bankId);
}
const key = await importKey(keyString);
const { encrypted, iv } = await encrypt(displayName, key);
const response = await fetch(store.url(), {
method: 'POST',
body: JSON.stringify({
name: encrypted,
name_iv: iv,
bank_id: finalBankId,
type: type,
currency_code: currencyCode,
}),
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
),
Accept: 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.message ||
Object.values(errorData.errors || {})[0] ||
'Failed to create account',
);
}
const accountData = await response.json();
onAccountCreated({
id: accountData.id || finalBankId,
name: displayName,
type: type,
currencyCode: currencyCode,
});
setIsSubmitting(false);
} catch (err) {
console.error('Account creation failed:', err);
setError(
err instanceof Error
? err.message
: 'Failed to create account. Please try again.',
);
setIsSubmitting(false);
}
}
const hasExistingAccounts = existingAccounts.length > 0;
const { title, description } = useMemo(() => {
if (hasExistingAccounts) {
return {
title: 'Your Accounts',
description:
"You already have accounts set up. Let's continue with the onboarding.",
};
}
if (isFirstAccount) {
return {
title: 'Create an Account',
description:
"Let's start with your main checking account. You can add more accounts later.",
};
}
return {
title: 'Add Another Account',
description: 'Add another account to track more of your finances.',
};
}, [hasExistingAccounts, isFirstAccount]);
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={CreditCard}
iconContainerClassName="bg-gradient-to-br from-emerald-400 to-teal-500"
title={title}
description={description}
/>
{hasExistingAccounts ? (
<div className="w-full max-w-md">
<div className="mb-6 space-y-2">
{existingAccounts.map((account) => (
<div
key={account.id}
className="flex items-center gap-3 rounded-lg border bg-card p-4"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="flex-1">
<p className="font-medium">
{account.bank?.name || 'Account'}
</p>
<p className="text-sm text-muted-foreground">
{formatAccountType(
account.type as AccountType,
)}{' '}
{account.currency_code}
</p>
</div>
</div>
))}
</div>
<StepButton
text="Continue"
className="w-full sm:w-full"
onClick={() =>
onAccountCreated({
id: existingAccounts[0].id,
name:
existingAccounts[0].bank?.name || 'Account',
type: existingAccounts[0].type,
currencyCode: existingAccounts[0].currency_code,
})
}
/>
</div>
) : (
<form
onSubmit={handleSubmit}
autoFocus
className="w-full max-w-md space-y-4"
>
<AccountForm
forceAccountType={
isFirstAccount ? 'checking' : undefined
}
onChange={handleFormChange}
/>
{isFirstAccount && (
<div className="rounded-lg border border-blue-100 bg-blue-50 p-3 text-sm dark:border-blue-900/50 dark:bg-blue-900/20">
<p className="text-center">
Your first account must be a{' '}
<strong>Checking</strong> account.
</p>
</div>
)}
{error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<StepButton
type="submit"
disabled={isSubmitting}
loading={isSubmitting}
loadingText="Creating..."
text="Create Account"
/>
{!isFirstAccount && onSkip && (
<Button
size="lg"
className="w-full opacity-50 transition-all duration-200 hover:opacity-100"
variant={'ghost'}
disabled={isSubmitting}
onClick={() => onSkip()}
>
Ignore
</Button>
)}
</form>
)}
</div>
);
}

View File

@ -0,0 +1,70 @@
import { StepButton } from '@/components/onboarding/step-button';
import { StepHeader } from '@/components/onboarding/step-header';
import { Button } from '@/components/ui/button';
import { Check, Settings, SkipForward } from 'lucide-react';
interface StepCustomizeCategoriesProps {
onContinue: () => void;
onSkip: () => void;
}
export function StepCustomizeCategories({
onContinue,
onSkip,
}: StepCustomizeCategoriesProps) {
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={Settings}
iconContainerClassName="bg-gradient-to-br from-pink-400 to-rose-500"
title="Customize Your Categories"
description="We've created a comprehensive set of categories for you. You can customize them now or adjust them later in settings."
/>
<div className="mb-8 w-full max-w-md rounded-xl border bg-card p-6">
<h3 className="mb-4 font-semibold">Your Categories Include:</h3>
<div className="space-y-2">
{[
'Food & Dining (Groceries, Restaurants, Delivery)',
'Housing (Rent, Utilities, Maintenance)',
'Transportation (Fuel, Public Transit, Parking)',
'Shopping (Clothing, Electronics, Gifts)',
'Entertainment (Movies, Sports, Hobbies)',
'Health & Wellness (Medical, Pharmacy, Fitness)',
'Income (Salary, Freelance, Investments)',
'Transfers (Between accounts, Savings)',
].map((category) => (
<div
key={category}
className="flex items-center gap-2 text-sm"
>
<Check className="h-4 w-4 shrink-0 text-emerald-500" />
<span>{category}</span>
</div>
))}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="ml-6">...and 40+ more</span>
</div>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button
variant="outline"
size="lg"
onClick={onSkip}
className="group gap-2"
>
<SkipForward className="h-4 w-4" />
Use Defaults
</Button>
<StepButton text="Continue" onClick={onContinue} />
</div>
<p className="mt-4 text-center text-xs text-muted-foreground">
You can always customize categories later in Settings
Categories
</p>
</div>
);
}

View File

@ -0,0 +1,95 @@
import { StepButton } from '@/components/onboarding/step-button';
import { StepHeader } from '@/components/onboarding/step-header';
import { Eye, EyeOff, Lock, Server, Shield, User } from 'lucide-react';
interface StepEncryptionExplainedProps {
onContinue: () => void;
}
export function StepEncryptionExplained({
onContinue,
}: StepEncryptionExplainedProps) {
return (
<div className="flex animate-in flex-col items-center text-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={Shield}
iconContainerClassName="bg-gradient-to-br from-blue-500 to-indigo-600"
title="Your Data, Your Privacy"
description="Whisper Money uses end-to-end encryption to protect your financial data. Here's how it works:"
/>
<div className="mb-5 grid w-full max-w-xl gap-4 sm:mb-4">
<Item
title="Create a Password"
description="Only you know this password. It never leaves your device."
icon={
<User className="size-4 text-emerald-600 dark:text-emerald-400" />
}
/>
<Item
title="Data is Encrypted"
description="Your data is encrypted before it leaves your browser."
icon={
<Lock className="size-4 text-blue-600 dark:text-blue-400" />
}
/>
<Item
title="We Can't Read It"
description="Even we can't access your data. It's truly private."
icon={
<Server className="size-4 text-violet-600 dark:text-violet-400" />
}
/>
</div>
<div className="mb-8 flex w-full max-w-xl flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-muted-foreground/20 p-4">
<div className="flex items-center gap-2">
<Eye className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">You see:</span>
<span className="font-mono text-emerald-600 dark:text-emerald-400">
STARBUCKS@TEKKA PLC
</span>
</div>
<div className="flex items-center gap-2">
<EyeOff className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">We see:</span>
<span className="font-mono text-muted-foreground">
$KO!F6LMHU1W%TAEQFZMD9
</span>
</div>
</div>
<StepButton
text="I Understand, Continue"
onClick={onContinue}
data-testid="encryption-continue-button"
/>
</div>
);
}
function Item({
title,
description,
icon,
}: {
title: string;
description: string;
icon: React.ReactNode;
}) {
return (
<div className="flex w-full flex-col items-start gap-2 rounded-xl border bg-card p-3 sm:p-5">
<div className="flex flex-row items-center gap-2">
{icon}
<h3 className="font-semibold">{title}</h3>
</div>
<div className="text-left sm:text-center">
<p className="text-sm text-pretty text-muted-foreground">
{description}
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,227 @@
import { StepHeader } from '@/components/onboarding/step-header';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import {
bufferToBase64,
encrypt,
exportKey,
generateSalt,
getAESKeyFromPBKDF,
getKeyFromPassword,
} from '@/lib/crypto';
import { storeKey } from '@/lib/key-storage';
import axios from 'axios';
import { AlertCircle, CheckCircle2, KeyRound } from 'lucide-react';
import { useState } from 'react';
import { StepButton } from './step-button';
interface StepEncryptionSetupProps {
onComplete: () => void;
}
export function StepEncryptionSetup({ onComplete }: StepEncryptionSetupProps) {
const { refreshKeyState } = useEncryptionKey();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [storagePreference, setStoragePreference] = useState<
'session' | 'persistent'
>('session');
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const passwordStrength = getPasswordStrength(password);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
if (password.length < 12) {
setError('Password must be at least 12 characters');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
setProcessing(true);
try {
const salt = generateSalt();
const pbkdfKey = await getKeyFromPassword(password);
const aesKey = await getAESKeyFromPBKDF(pbkdfKey, salt);
const { encrypted, iv } = await encrypt('Hello, world', aesKey);
const exportedKey = await exportKey(aesKey);
await axios.post('/api/encryption/setup', {
salt: bufferToBase64(salt),
encrypted_content: encrypted,
iv: iv,
});
storeKey(exportedKey, storagePreference === 'persistent');
refreshKeyState();
onComplete();
} catch (err) {
console.error('Encryption setup error:', err);
setError('Failed to setup encryption. Please try again.');
setProcessing(false);
}
}
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={KeyRound}
iconContainerClassName="bg-gradient-to-br from-amber-400 to-orange-500"
title="Create Your Encryption Password"
description="This password will encrypt all your financial data. Make it strong and memorable — we can't recover it for you."
/>
<form onSubmit={handleSubmit} className="w-full max-w-md space-y-6">
<div className="space-y-2">
<Label htmlFor="password">Encryption Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter a strong password"
disabled={processing}
autoComplete="new-password"
required
minLength={12}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-1 items-center gap-2">
<div className="flex flex-1 gap-1">
{[1, 2, 3, 4].map((level) => (
<div
key={level}
className={`h-1.5 flex-1 rounded-full transition-colors ${
level <= passwordStrength.level
? passwordStrength.color
: 'bg-muted'
}`}
/>
))}
</div>
<span className="text-xs text-muted-foreground">
{passwordStrength.label}
</span>
</div>
<span
className={`text-xs ${password.length >= 12 ? 'text-emerald-600' : 'text-muted-foreground'}`}
>
{password.length}/12 min
</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
disabled={processing}
autoComplete="new-password"
required
/>
{confirmPassword && password === confirmPassword && (
<div className="flex items-center gap-1 text-xs text-emerald-600">
<CheckCircle2 className="h-3 w-3" />
Passwords match
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="storagePreference">Key Storage</Label>
<Select
value={storagePreference}
onValueChange={(value) =>
setStoragePreference(
value as 'session' | 'persistent',
)
}
disabled={processing}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="session">
Session only (more secure)
</SelectItem>
<SelectItem value="persistent">
Keep me logged in (convenient)
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{storagePreference === 'session'
? 'Your key will be cleared when you close the browser.'
: 'Your key will be stored until you log out.'}
</p>
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<StepButton
type="submit"
disabled={processing || password.length < 12}
loading={processing}
loadingText="Setting up encryption..."
text={'Setup Encryption'}
className="w-full sm:w-full"
/>
</form>
</div>
);
}
function getPasswordStrength(password: string): {
level: number;
label: string;
color: string;
} {
if (!password) {
return { level: 0, label: '', color: 'bg-muted' };
}
let score = 0;
if (password.length >= 6) score++;
if (password.length >= 12) score++;
if (/[A-Z]/.test(password) || /[a-z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (score <= 1) {
return { level: 1, label: 'Weak', color: 'bg-red-500' };
}
if (score === 2) {
return { level: 2, label: 'Fair', color: 'bg-orange-500' };
}
if (score === 3) {
return { level: 3, label: 'Good', color: 'bg-yellow-500' };
}
return { level: 4, label: 'Strong', color: 'bg-emerald-500' };
}

View File

@ -0,0 +1,57 @@
import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react';
interface StepHeaderProps {
icon: LucideIcon;
iconContainerClassName?: string;
title: string;
description: string;
/** Use larger icon container (h-24 w-24) for welcome/complete screens */
large?: boolean;
}
export function StepHeader({
icon: Icon,
iconContainerClassName,
title,
description,
large = false,
}: StepHeaderProps) {
return (
<>
<div
className={cn(
'flex animate-in items-center justify-center rounded-full shadow-lg duration-500 zoom-in',
large ? 'mb-8 size-24' : 'mb-6 size-16',
iconContainerClassName,
)}
>
<Icon
className={cn(
'text-white',
large ? 'h-12 w-12' : 'h-10 w-10',
)}
/>
</div>
<h1
className={cn(
'text-center font-bold tracking-tight text-balance',
large
? 'mb-4 text-4xl leading-tight sm:text-4xl md:text-5xl'
: 'mb-2 text-3xl',
)}
dangerouslySetInnerHTML={{ __html: title }}
/>
<p
className={cn(
'mb-8 max-w-lg text-center text-balance text-muted-foreground',
large && 'text-lg',
)}
>
{description}
</p>
</>
);
}

View File

@ -0,0 +1,118 @@
import { StepButton } from '@/components/onboarding/step-button';
import { StepHeader } from '@/components/onboarding/step-header';
import { AmountInput } from '@/components/ui/amount-input';
import { Label } from '@/components/ui/label';
import { CreatedAccount } from '@/hooks/use-onboarding-state';
import { AlertCircle, TrendingUp, Wallet } from 'lucide-react';
import { useMemo, useState } from 'react';
interface StepImportBalancesProps {
account: CreatedAccount | undefined;
onComplete: () => void;
}
export function StepImportBalances({
account,
onComplete,
}: StepImportBalancesProps) {
const [balanceInCents, setBalanceInCents] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
if (balanceInCents === 0) {
setError('Please enter a balance');
return;
}
setIsSubmitting(true);
try {
// TODO: Save balance to backend
onComplete();
} catch (err) {
console.error('Failed to set balance:', err);
setError('Failed to set balance. Please try again.');
setIsSubmitting(false);
}
}
const description = useMemo(() => {
return account
? `"${account.name}" is a ${account.type} account. These accounts track balance changes over time instead of individual transactions.`
: 'Set the current balance for this account to start tracking.';
}, [account]);
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={TrendingUp}
iconContainerClassName="bg-gradient-to-br from-amber-400 to-orange-500"
title="Set Account Balance"
description={description}
/>
<div className="mb-6 w-full max-w-md rounded-xl border bg-card p-6">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/30">
<Wallet className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h3 className="font-semibold">Balance Tracking</h3>
<p className="text-sm text-muted-foreground">
Perfect for investment portfolios and retirement
accounts
</p>
</div>
</div>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-amber-500" />
Update balances periodically to track growth
</li>
<li className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-amber-500" />
Import balance history from CSV files
</li>
<li className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-amber-500" />
View balance evolution over time
</li>
</ul>
</div>
<form onSubmit={handleSubmit} className="w-full max-w-md space-y-4">
<div className="space-y-2">
<Label htmlFor="balance">Current Balance</Label>
<AmountInput
id="balance"
value={balanceInCents}
onChange={setBalanceInCents}
currencyCode={account?.currencyCode || 'USD'}
disabled={isSubmitting}
required
/>
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<StepButton
type="submit"
className="w-full sm:w-full"
text="Save Balance"
loading={isSubmitting}
loadingText="Saving..."
/>
</form>
</div>
);
}

View File

@ -0,0 +1,119 @@
import { StepHeader } from '@/components/onboarding/step-header';
import { ImportTransactionsDrawer } from '@/components/transactions/import-transactions-drawer';
import { Button } from '@/components/ui/button';
import { CreatedAccount } from '@/hooks/use-onboarding-state';
import { ArrowRight, FileSpreadsheet, Upload } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
interface StepImportTransactionsProps {
account: CreatedAccount | undefined;
onComplete: () => void;
}
export function StepImportTransactions({
account,
onComplete,
}: StepImportTransactionsProps) {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [hasImported, setHasImported] = useState(false);
const handleDrawerClose = (open: boolean) => {
setIsDrawerOpen(open);
if (!open) {
setHasImported(true);
}
};
useEffect(() => {
if (hasImported) {
onComplete();
}
}, [hasImported, onComplete]);
const description = useMemo(() => {
return account
? `Import transactions for "${account.name}". You can export transaction history from your bank's website.`
: 'Import your transaction history to start tracking your finances.';
}, [account]);
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={Upload}
iconContainerClassName="bg-gradient-to-br from-indigo-400 to-purple-500"
title="Import Your Transactions"
description={description}
/>
<div className="mb-4 w-full max-w-md rounded-xl border bg-card p-6">
<h3 className="mb-4 font-semibold">
How to Export from Your Bank:
</h3>
<ol className="space-y-3 text-sm text-muted-foreground">
<li className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
1
</span>
<span>Log in to your bank's website or app</span>
</li>
<li className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
2
</span>
<span>Go to your account's transaction history</span>
</li>
<li className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
3
</span>
<span>Look for "Export" or "Download" option</span>
</li>
<li className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
4
</span>
<span>Download as CSV or Excel format</span>
</li>
</ol>
</div>
<div className="mb-6 flex w-full max-w-md items-center gap-4 rounded-lg border border-dashed border-muted-foreground/30 p-4">
<FileSpreadsheet className="size-10 rounded-full bg-muted p-2.5 text-muted-foreground" />
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Supported formats</p>
<p className="text-xs text-muted-foreground">
CSV, XLS, XLSX files
</p>
</div>
</div>
<div className="flex w-full flex-col gap-3 sm:w-auto">
<Button
size="lg"
onClick={() => setIsDrawerOpen(true)}
className="group w-full gap-2 !px-8 py-6 sm:w-auto"
>
<Upload className="h-4 w-4" />
Import Transactions
</Button>
{hasImported && (
<Button
variant="outline"
size="lg"
onClick={onComplete}
className="group gap-2"
>
Continue
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</Button>
)}
</div>
<ImportTransactionsDrawer
open={isDrawerOpen}
onOpenChange={handleDrawerClose}
/>
</div>
);
}

View File

@ -0,0 +1,118 @@
import { StepHeader } from '@/components/onboarding/step-header';
import { Button } from '@/components/ui/button';
import { CreatedAccount } from '@/hooks/use-onboarding-state';
import { formatAccountType } from '@/types/account';
import { Check, CheckCircle2, Plus, Wallet } from 'lucide-react';
import { useMemo } from 'react';
import { StepButton } from './step-button';
interface ExistingAccount {
id: string;
name: string;
name_iv: string;
type: string;
currency_code: string;
bank_id: string;
bank?: {
id: string;
name: string;
logo: string | null;
};
}
interface StepMoreAccountsProps {
createdAccounts: CreatedAccount[];
existingAccounts?: ExistingAccount[];
onAddMore: () => void;
onFinish: () => void;
}
export function StepMoreAccounts({
createdAccounts,
existingAccounts = [],
onAddMore,
onFinish,
}: StepMoreAccountsProps) {
const totalAccounts = createdAccounts.length + existingAccounts.length;
const description = useMemo(() => {
return `You've set up ${totalAccounts} account${totalAccounts !== 1 ? 's' : ''}. Would you like to add more or continue to the dashboard?`;
}, [totalAccounts]);
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={Wallet}
iconContainerClassName="bg-gradient-to-br from-teal-400 to-cyan-500"
title="Great Progress!"
description={description}
/>
<div className="mb-8 w-full max-w-md">
<h3 className="mb-3 text-sm font-medium text-muted-foreground">
Your Accounts
</h3>
<div className="space-y-2">
{createdAccounts.map((account) => (
<div
key={account.id}
className="flex items-center gap-3 rounded-lg border bg-card p-4"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="flex-1">
<p className="font-medium">{account.name}</p>
<p className="text-sm text-muted-foreground">
{formatAccountType(account.type)} {' '}
{account.currencyCode}
</p>
</div>
<Check className="h-5 w-5 text-emerald-500" />
</div>
))}
{existingAccounts.map((account) => (
<div
key={account.id}
className="flex items-center gap-3 rounded-lg border bg-card p-4"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="flex-1">
<p className="font-medium text-muted-foreground">
{account.bank?.name || 'Account'}
</p>
<p className="text-sm text-muted-foreground">
{formatAccountType(account.type)} {' '}
{account.currency_code}
</p>
</div>
<Check className="h-5 w-5 text-emerald-500" />
</div>
))}
</div>
</div>
<div className="mb-6 w-full max-w-md rounded-xl border-2 border-dashed border-muted-foreground/20 p-6">
<div className="text-center">
<h3 className="mb-1 font-semibold">Add More Accounts?</h3>
<p className="mb-4 text-sm text-muted-foreground">
Track all your finances in one place checking,
savings, credit cards, investments, and more.
</p>
<Button
variant="outline"
onClick={onAddMore}
className="w-full gap-2 !py-6"
>
<Plus className="h-4 w-4" />
Add Another Account
</Button>
</div>
</div>
<StepButton text="Finish Setup" onClick={onFinish} />
</div>
);
}

View File

@ -0,0 +1,108 @@
import { StepButton } from '@/components/onboarding/step-button';
import { StepHeader } from '@/components/onboarding/step-header';
import { Bot, Eye, EyeOff, Shield, Sparkles, Zap } from 'lucide-react';
interface StepSmartRulesProps {
onContinue: () => void;
}
export function StepSmartRules({ onContinue }: StepSmartRulesProps) {
return (
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={Zap}
iconContainerClassName="bg-gradient-to-br from-yellow-400 to-amber-500"
title="Smart Automation Rules"
description="Create rules to automatically categorize your transactions based on patterns you define."
/>
<div className="mb-6 grid w-full max-w-2xl gap-4 md:grid-cols-2">
<div className="rounded-xl border bg-card p-5">
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
<Sparkles className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<h3 className="mb-2 font-semibold">Pattern Matching</h3>
<p className="text-sm text-muted-foreground">
Create rules like "If description contains 'AMAZON',
categorize as Shopping"
</p>
</div>
<div className="rounded-xl border bg-card p-5">
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/30">
<Zap className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="mb-2 font-semibold">Instant Application</h3>
<p className="text-sm text-muted-foreground">
Rules apply automatically when you import new
transactions
</p>
</div>
</div>
<div className="mb-8 w-full max-w-2xl rounded-xl border-2 border-amber-200 bg-amber-50 p-6 dark:border-amber-900/50 dark:bg-amber-900/20">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-200 dark:bg-amber-800">
<Shield className="h-6 w-6 text-amber-700 dark:text-amber-300" />
</div>
<div>
<h3 className="font-semibold text-amber-900 dark:text-amber-100">
Why No AI Auto-Categorization?
</h3>
<p className="text-sm text-amber-800 dark:text-amber-200">
Privacy comes first
</p>
</div>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<Bot className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />
</div>
<div>
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
AI requires sending your data to external
servers
</p>
<p className="text-xs text-amber-700 dark:text-amber-300">
This would break our end-to-end encryption
promise
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
<EyeOff className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
Your rules run entirely in your browser
</p>
<p className="text-xs text-amber-700 dark:text-amber-300">
We never see your transaction descriptions
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
<Eye className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
You're in complete control
</p>
<p className="text-xs text-amber-700 dark:text-amber-300">
Create, edit, and delete rules anytime
</p>
</div>
</div>
</div>
</div>
<StepButton text="Continue to Import" onClick={onContinue} />
</div>
);
}

View File

@ -0,0 +1,53 @@
import { StepButton } from '@/components/onboarding/step-button';
import { StepHeader } from '@/components/onboarding/step-header';
import { bankSyncService } from '@/services/bank-sync';
import { Bird } from 'lucide-react';
import { useEffect, useState } from 'react';
interface StepWelcomeProps {
onContinue: () => void;
}
export function StepWelcome({ onContinue }: StepWelcomeProps) {
const [isSyncing, setIsSyncing] = useState(true);
useEffect(() => {
const syncBanks = async () => {
try {
await bankSyncService.sync();
} catch (error) {
console.error('Failed to sync banks:', error);
} finally {
setIsSyncing(false);
}
};
syncBanks();
}, []);
return (
<div className="flex animate-in flex-col items-center text-center duration-500 fade-in slide-in-from-bottom-4">
<StepHeader
icon={Bird}
iconContainerClassName="bg-gradient-to-br from-black to-zinc-700"
title="Welcome to</br>Whisper Money"
description="Take control of your finances with privacy-first money tracking. Let's set up your account in just a few minutes."
large
/>
<div className="flex w-full flex-col gap-4 sm:w-auto">
<StepButton
text="Let's Get Started"
onClick={onContinue}
disabled={isSyncing}
loading={isSyncing}
loadingText="Preparing..."
/>
<p className="text-sm text-muted-foreground">
This will take less than 5 minutes
</p>
</div>
</div>
);
}

View File

@ -37,6 +37,14 @@ export function ImportStepAccount({
loadAccounts();
}, []);
// If there is only one account, auto-select it, and proceed to next step
useEffect(() => {
if (!loading && accounts.length === 1) {
onAccountSelect(accounts[0].id);
onNext();
}
}, [loading, accounts, onAccountSelect, onNext]);
if (loading) {
return (
<div className="flex items-center justify-center py-8">

View File

@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';
interface UseCountUpOptions {
duration?: number;
delay?: number;
}
export function useCountUp(
target: number,
options: UseCountUpOptions = {},
): number {
const { duration = 1500, delay = 0 } = options;
const [count, setCount] = useState(0);
useEffect(() => {
if (target === 0) {
setCount(0);
return;
}
const delayTimeout = setTimeout(() => {
const startTime = performance.now();
const startValue = 0;
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOutQuad = 1 - (1 - progress) * (1 - progress);
const currentValue = Math.round(
startValue + (target - startValue) * easeOutQuad,
);
setCount(currentValue);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}, delay);
return () => clearTimeout(delayTimeout);
}, [target, duration, delay]);
return count;
}

View File

@ -0,0 +1,146 @@
import { getStoredKey } from '@/lib/key-storage';
import { useCallback, useMemo, useState } from 'react';
export type OnboardingStep =
| 'welcome'
| 'encryption-explained'
| 'encryption-setup'
| 'account-types'
| 'create-account'
| 'category-types'
| 'customize-categories'
| 'smart-rules'
| 'import-transactions'
| 'import-balances'
| 'more-accounts'
| 'complete';
// Primary steps shown in the progress indicator
// import-transactions and import-balances are sub-steps that don't increment the counter
const PRIMARY_STEPS: OnboardingStep[] = [
'welcome',
'encryption-explained',
'encryption-setup',
'account-types',
'create-account',
'category-types',
'customize-categories',
'smart-rules',
'more-accounts',
'complete',
];
// Steps that are sub-steps (shown under the same progress position as 'create-account')
const SUB_STEPS: OnboardingStep[] = ['import-transactions', 'import-balances'];
const SKIPPABLE_ENCRYPTION_STEPS: OnboardingStep[] = ['encryption-setup'];
export interface OnboardingState {
currentStep: OnboardingStep;
stepIndex: number;
totalSteps: number;
createdAccounts: CreatedAccount[];
isFirstAccount: boolean;
}
export interface CreatedAccount {
id: string;
name: string;
type: string;
currencyCode: string;
}
interface UseOnboardingStateOptions {
existingAccountsCount?: number;
hasEncryptionSetup?: boolean;
}
export function useOnboardingState(options: UseOnboardingStateOptions = {}) {
const { existingAccountsCount = 0, hasEncryptionSetup = false } = options;
// Check both: backend says encryption is set up AND we have the key in browser storage
// We need the key in storage to actually decrypt data
const hasEncryptionKey = useMemo(() => {
// If backend says encryption is not set up, we definitely don't have a key
if (!hasEncryptionSetup) {
return false;
}
// If backend says it's set up, also check if we have the key locally
return getStoredKey() !== null;
}, [hasEncryptionSetup]);
const primarySteps = useMemo(() => {
if (hasEncryptionKey) {
return PRIMARY_STEPS.filter(
(step) => !SKIPPABLE_ENCRYPTION_STEPS.includes(step),
);
}
return PRIMARY_STEPS;
}, [hasEncryptionKey]);
// Determine initial step based on existing state
const initialStep = useMemo((): OnboardingStep => {
return 'welcome';
}, []);
const [currentStep, setCurrentStep] = useState<OnboardingStep>(initialStep);
const [createdAccounts, setCreatedAccounts] = useState<CreatedAccount[]>(
[],
);
// Calculate step index for progress indicator
// Sub-steps (import-transactions, import-balances) use the same index as 'create-account'
const stepIndex = useMemo(() => {
if (SUB_STEPS.includes(currentStep)) {
// Sub-steps show under the 'create-account' position
return primarySteps.indexOf('create-account');
}
return primarySteps.indexOf(currentStep);
}, [currentStep, primarySteps]);
const totalSteps = primarySteps.length;
const goToStep = useCallback((step: OnboardingStep) => {
setCurrentStep(step);
}, []);
const goNext = useCallback(() => {
// Find the next primary step
const primaryIndex = primarySteps.indexOf(currentStep);
if (primaryIndex >= 0 && primaryIndex < primarySteps.length - 1) {
setCurrentStep(primarySteps[primaryIndex + 1]);
}
}, [currentStep, primarySteps]);
const goBack = useCallback(() => {
// If we're on a sub-step, go back to create-account
if (SUB_STEPS.includes(currentStep)) {
setCurrentStep('create-account');
return;
}
const primaryIndex = primarySteps.indexOf(currentStep);
if (primaryIndex > 0) {
setCurrentStep(primarySteps[primaryIndex - 1]);
}
}, [currentStep, primarySteps]);
const addCreatedAccount = useCallback((account: CreatedAccount) => {
setCreatedAccounts((prev) => [...prev, account]);
}, []);
const isFirstAccount =
createdAccounts.length === 0 && existingAccountsCount === 0;
return {
currentStep,
stepIndex,
totalSteps,
createdAccounts,
isFirstAccount,
goToStep,
goNext,
goBack,
addCreatedAccount,
hasEncryptionKey,
};
}

View File

@ -0,0 +1,69 @@
import AppLogoIcon from '@/components/app-logo-icon';
import { cn } from '@/lib/utils';
import { type PropsWithChildren, useEffect, useState } from 'react';
interface OnboardingLayoutProps {
currentStep: number;
totalSteps: number;
stepKey: string;
}
export default function OnboardingLayout({
children,
currentStep,
totalSteps,
stepKey,
}: PropsWithChildren<OnboardingLayoutProps>) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(false);
const timer = setTimeout(() => setIsVisible(true), 50);
return () => clearTimeout(timer);
}, [stepKey]);
return (
<div className="flex min-h-svh flex-col bg-background">
<header className="flex items-center justify-between p-4 md:p-6">
<AppLogoIcon
className={cn([
'size-6 fill-current text-[var(--foreground)] sm:opacity-75 dark:text-white',
{ 'opacity-0': currentStep === 0 },
])}
/>
<div className="flex items-center gap-2">
{Array.from({ length: totalSteps }).map((_, index) => (
<div
key={index}
className={cn(
'h-2 w-2 rounded-full transition-all duration-300',
index < currentStep
? 'bg-primary'
: index === currentStep
? 'scale-125 bg-primary'
: 'bg-primary/15',
)}
/>
))}
</div>
<div className="size-6"></div>
</header>
<main className="flex flex-1 flex-col items-center justify-start px-4 pt-12 sm:justify-center md:px-6">
<div
key={stepKey}
className={cn(
'w-full max-w-2xl transition-all duration-300 ease-out',
isVisible
? 'translate-y-0 opacity-100'
: 'translate-y-5 opacity-0',
)}
>
{children}
</div>
</main>
</div>
);
}

View File

@ -1,56 +1,74 @@
import type { UUID } from '@/types/uuid';
import { db } from './dexie-db';
const USER_ID_KEY = 'wm_user_id';
const USER_ID_KEY = 'current_user_id';
function isBrowser(): boolean {
return typeof window !== 'undefined';
}
export function getStoredUserId(): UUID | null {
export async function getStoredUserId(): Promise<UUID | null> {
if (!isBrowser()) return null;
return localStorage.getItem(USER_ID_KEY) as UUID | null;
try {
await db.open();
const metadata = await db.sync_metadata.get(USER_ID_KEY);
return (metadata?.value as UUID) || null;
} catch {
return null;
}
}
export function setStoredUserId(userId: UUID): void {
export async function setStoredUserId(userId: UUID): Promise<void> {
if (!isBrowser()) return;
localStorage.setItem(USER_ID_KEY, userId);
try {
await db.open();
await db.sync_metadata.put({ key: USER_ID_KEY, value: userId });
} catch (error) {
console.error('Failed to store user ID:', error);
}
}
export async function clearAllUserData(): Promise<void> {
if (!isBrowser()) return;
await db.delete();
const keysToPreserve = [USER_ID_KEY];
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && !keysToPreserve.includes(key)) {
keysToRemove.push(key);
}
// Clear all tables including sync_metadata (which contains last_sync timestamps)
// This ensures a fresh start for the new user without stale sync timestamps
try {
await db.open();
await Promise.all([
db.transactions.clear(),
db.accounts.clear(),
db.categories.clear(),
db.banks.clear(),
db.automation_rules.clear(),
db.account_balances.clear(),
db.sync_metadata.clear(),
db.pending_changes.clear(),
]);
} catch {
// If clearing fails, delete and recreate the database
await db.delete();
await db.open();
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
sessionStorage.clear();
}
export async function handleUserChange(newUserId: UUID): Promise<boolean> {
const storedUserId = getStoredUserId();
const storedUserId = await getStoredUserId();
// Different user logged in - clear all data and set new user
if (storedUserId && storedUserId !== newUserId) {
await clearAllUserData();
setStoredUserId(newUserId);
await setStoredUserId(newUserId);
return true;
}
// No stored user ID (first time or after signup/clear) - store the new user ID
if (!storedUserId) {
setStoredUserId(newUserId);
await setStoredUserId(newUserId);
}
// Same user or just stored - no clearing needed
return false;
}

View File

@ -0,0 +1,226 @@
import { StepAccountTypes } from '@/components/onboarding/step-account-types';
import { StepCategoryTypes } from '@/components/onboarding/step-category-types';
import { StepComplete } from '@/components/onboarding/step-complete';
import { StepCreateAccount } from '@/components/onboarding/step-create-account';
import { StepCustomizeCategories } from '@/components/onboarding/step-customize-categories';
import { StepEncryptionExplained } from '@/components/onboarding/step-encryption-explained';
import { StepEncryptionSetup } from '@/components/onboarding/step-encryption-setup';
import { StepImportBalances } from '@/components/onboarding/step-import-balances';
import { StepImportTransactions } from '@/components/onboarding/step-import-transactions';
import { StepMoreAccounts } from '@/components/onboarding/step-more-accounts';
import { StepSmartRules } from '@/components/onboarding/step-smart-rules';
import { StepWelcome } from '@/components/onboarding/step-welcome';
import { useSyncContext } from '@/contexts/sync-context';
import {
CreatedAccount,
OnboardingStep,
useOnboardingState,
} from '@/hooks/use-onboarding-state';
import OnboardingLayout from '@/layouts/onboarding-layout';
import { useTrackEvent } from '@/lib/track-event';
import { type Bank } from '@/types/account';
import { Head } from '@inertiajs/react';
import { useEffect, useRef } from 'react';
const ONBOARDING_FUNNEL_EVENT_UUID = '1478ba44-c5c4-4398-83a1-814b21cd7e34';
interface ExistingAccount {
id: string;
name: string;
name_iv: string;
type: string;
currency_code: string;
bank_id: string;
bank?: {
id: string;
name: string;
logo: string | null;
};
}
interface OnboardingProps {
banks: Bank[];
accounts: ExistingAccount[];
hasEncryptionSetup: boolean;
}
export default function Onboarding({
banks,
accounts,
hasEncryptionSetup,
}: OnboardingProps) {
const { sync } = useSyncContext();
const trackEvent = useTrackEvent();
const hasSyncedRef = useRef(false);
const trackedStepsRef = useRef<Set<OnboardingStep>>(new Set());
// Sync banks on mount to ensure IndexedDB has the latest data
useEffect(() => {
if (!hasSyncedRef.current) {
hasSyncedRef.current = true;
sync();
}
}, [sync]);
const {
currentStep,
stepIndex,
totalSteps,
createdAccounts,
isFirstAccount,
goToStep,
goNext,
addCreatedAccount,
} = useOnboardingState({
existingAccountsCount: accounts.length,
hasEncryptionSetup,
});
useEffect(() => {
if (!trackedStepsRef.current.has(currentStep)) {
trackedStepsRef.current.add(currentStep);
trackEvent(ONBOARDING_FUNNEL_EVENT_UUID, { step: currentStep });
}
}, [currentStep, trackEvent]);
const handleAccountCreated = async (account: CreatedAccount) => {
addCreatedAccount(account);
// Sync with backend to get the new account in local DB
await sync();
const needsTransactionImport = [
'checking',
'savings',
'credit_card',
].includes(account.type);
if (needsTransactionImport) {
goToStep('import-transactions');
} else {
goToStep('import-balances');
}
};
const handleImportComplete = async () => {
// Sync after import to ensure data is consistent
await sync();
goToStep('more-accounts');
};
const handleAddMoreAccounts = () => {
goToStep('create-account');
};
const handleFinishOnboarding = () => {
goToStep('complete');
};
const renderStep = () => {
const lastAccount = createdAccounts[createdAccounts.length - 1];
switch (currentStep) {
case 'welcome':
return <StepWelcome onContinue={goNext} />;
case 'encryption-explained':
return <StepEncryptionExplained onContinue={goNext} />;
case 'encryption-setup':
return <StepEncryptionSetup onComplete={goNext} />;
case 'account-types':
return <StepAccountTypes onContinue={goNext} />;
case 'create-account':
return (
<StepCreateAccount
banks={banks}
isFirstAccount={isFirstAccount}
existingAccounts={accounts}
onAccountCreated={handleAccountCreated}
onSkip={() => {
goToStep('more-accounts');
}}
/>
);
case 'category-types':
return <StepCategoryTypes onContinue={goNext} />;
case 'customize-categories':
return (
<StepCustomizeCategories
onContinue={goNext}
onSkip={goNext}
/>
);
case 'smart-rules':
return <StepSmartRules onContinue={goNext} />;
case 'import-transactions':
return (
<StepImportTransactions
account={lastAccount}
onComplete={handleImportComplete}
/>
);
case 'import-balances':
return (
<StepImportBalances
account={lastAccount}
onComplete={handleImportComplete}
/>
);
case 'more-accounts':
return (
<StepMoreAccounts
createdAccounts={createdAccounts}
existingAccounts={accounts}
onAddMore={handleAddMoreAccounts}
onFinish={handleFinishOnboarding}
/>
);
case 'complete':
return <StepComplete />;
default:
return null;
}
};
const getStepTitle = (step: OnboardingStep): string => {
const titles: Record<OnboardingStep, string> = {
welcome: 'Welcome',
'encryption-explained': 'End-to-End Encryption',
'encryption-setup': 'Setup Encryption',
'account-types': 'Account Types',
'create-account': 'Create Account',
'category-types': 'Categories',
'customize-categories': 'Customize Categories',
'smart-rules': 'Smart Rules',
'import-transactions': 'Import Transactions',
'import-balances': 'Set Balance',
'more-accounts': 'Add More Accounts',
complete: 'All Set!',
};
return titles[step];
};
return (
<>
<Head title={`Onboarding - ${getStepTitle(currentStep)}`} />
<OnboardingLayout
currentStep={stepIndex}
totalSteps={totalSteps}
stepKey={currentStep}
>
{renderStep()}
</OnboardingLayout>
</>
);
}

View File

@ -1,95 +1,368 @@
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useCountUp } from '@/hooks/use-count-up';
import { cn } from '@/lib/utils';
import { checkout } from '@/routes/subscribe';
import { type SharedData } from '@/types';
import { Plan } from '@/types/pricing';
import { Head, usePage } from '@inertiajs/react';
import { CheckIcon } from 'lucide-react';
import {
CheckIcon,
FolderIcon,
LockIcon,
PiggyBankIcon,
ReceiptIcon,
TrendingUpIcon,
UsersIcon,
WalletIcon,
} from 'lucide-react';
import { useEffect, useState } from 'react';
function getBillingLabel(billingPeriod: string | null): string {
interface PaywallStats {
accountsCount: number;
transactionsCount: number;
categoriesCount: number;
automationRulesCount: number;
balancesByCurrency: Record<string, number>;
}
interface PaywallPageProps extends SharedData {
stats: PaywallStats;
}
function formatCurrency(amount: number, currencyCode: string): string {
const absAmount = Math.abs(amount) / 100;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(absAmount);
}
function getEquivalentBillingLabel(billingPeriod: string | null): string {
if (!billingPeriod) {
return 'one-time';
}
if (billingPeriod === 'year') {
return '/month';
}
return `/${billingPeriod}`;
}
function PlanCard({
planKey,
plan,
isDefault,
isBestValue,
}: {
planKey: string;
plan: Plan;
isDefault: boolean;
isBestValue: boolean;
}) {
const socialProofs = [
{
icon: TrendingUpIcon,
highlight: '15% more savings',
text: 'after 3 months with Whisper Money',
},
{
icon: PiggyBankIcon,
highlight: '23% better',
text: 'spending awareness reported',
},
{
icon: LockIcon,
highlight: '100% private',
text: '- we never sell your data',
},
{
icon: UsersIcon,
highlight: '1,200+ users',
text: 'taking control of their finances',
},
];
function SocialProofSlider() {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % socialProofs.length);
}, 4000);
return () => clearInterval(interval);
}, []);
const currentProof = socialProofs[currentIndex];
const Icon = currentProof.icon;
return (
<div
className={cn(
'relative flex flex-col overflow-hidden rounded-xl border bg-card',
isDefault && 'border-emerald-500 ring-2 ring-emerald-500',
isBestValue && 'border-blue-500 shadow-xl ring-1 ring-blue-500',
)}
>
{isDefault && (
<div className="bg-emerald-500 p-2 text-center text-xs font-medium text-white">
Most Popular
</div>
)}
{isBestValue && (
<div className="bg-blue-50 p-2 text-center text-xs font-medium text-blue-500">
Best Value
</div>
)}
<div className="flex flex-1 flex-col p-5">
<h3 className="text-lg font-semibold">{plan.name}</h3>
<div className="mt-2 flex items-baseline gap-1">
{plan.original_price && (
<span className="text-sm text-muted-foreground line-through">
${plan.original_price}
</span>
)}
<span className="text-3xl font-bold">${plan.price}</span>
<span className="text-sm text-muted-foreground">
{getBillingLabel(plan.billing_period)}
<div className="flex flex-col items-center gap-4">
<div
key={`icon-${currentIndex}`}
className="flex h-16 w-16 animate-in items-center justify-center rounded-full bg-emerald-100 duration-500 zoom-in-95 fade-in dark:bg-emerald-900/30"
>
<Icon className="h-8 w-8 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="relative w-full overflow-hidden text-center">
<p
key={currentIndex}
className="animate-in text-lg text-balance duration-500 fade-in slide-in-from-right-4"
>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
{currentProof.highlight}
</span>{' '}
<span className="text-muted-foreground">
{currentProof.text}
</span>
</div>
</p>
</div>
<ul className="mt-4 flex-1 space-y-2">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2">
<CheckIcon className="size-4 shrink-0 text-emerald-500" />
<span className="text-sm">{feature}</span>
</li>
))}
</ul>
<a href={checkout.url({ plan: planKey })} className="mt-6">
<Button
<div className="flex gap-1.5">
{socialProofs.map((_, index) => (
<button
key={index}
onClick={() => setCurrentIndex(index)}
className={cn(
'w-full cursor-pointer',
isDefault &&
'bg-emerald-600 hover:bg-emerald-700 dark:bg-emerald-600 dark:hover:bg-emerald-700',
'h-1.5 rounded-full transition-all',
index === currentIndex
? 'w-4 bg-emerald-500'
: 'w-1.5 bg-muted-foreground/30 hover:bg-muted-foreground/50',
)}
variant={isDefault ? 'default' : 'outline'}
>
{plan.billing_period ? 'Subscribe' : 'Buy Now'}
</Button>
</a>
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
</div>
);
}
function StatItem({
icon: Icon,
value,
label,
delay = 0,
}: {
icon: React.ElementType;
value: number;
label: string;
delay?: number;
}) {
const animatedValue = useCountUp(value, { delay });
return (
<div className="flex flex-1 flex-col items-center gap-0.5">
<Icon className="mb-1.5 h-4 w-4 text-emerald-500" />
<span className="text-xl font-bold">{animatedValue}</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
);
}
function BalanceDisplay({
balancesByCurrency,
}: {
balancesByCurrency: Record<string, number>;
}) {
const entries = Object.entries(balancesByCurrency);
if (entries.length === 0) {
return null;
}
return (
<div className="flex flex-1 flex-col items-center gap-0.5">
<WalletIcon className="mb-1.5 h-4 w-4 text-emerald-500" />
<div className="flex flex-col items-center">
{entries.map(([currency, amount]) => (
<span key={currency} className="text-xl font-bold">
{formatCurrency(amount, currency)}
</span>
))}
</div>
<span className="text-xs text-muted-foreground">Balance</span>
</div>
);
}
function FinancialSnapshot({ stats }: { stats: PaywallStats }) {
const hasData =
stats.accountsCount > 0 ||
stats.transactionsCount > 0 ||
stats.categoriesCount > 0;
if (!hasData) {
return null;
}
return (
<Card className="animate-in duration-500 [animation-delay:200ms] fade-in">
<CardContent className="flex flex-row gap-6">
{stats.accountsCount > 0 && (
<StatItem
icon={PiggyBankIcon}
value={stats.accountsCount}
label="Accounts"
delay={100}
/>
)}
{stats.transactionsCount > 0 && (
<StatItem
icon={ReceiptIcon}
value={stats.transactionsCount}
label="Transactions"
delay={200}
/>
)}
{stats.categoriesCount > 0 && (
<StatItem
icon={FolderIcon}
value={stats.categoriesCount}
label="Categories"
delay={300}
/>
)}
{Object.keys(stats.balancesByCurrency).length > 0 && (
<BalanceDisplay
balancesByCurrency={stats.balancesByCurrency}
/>
)}
</CardContent>
</Card>
);
}
function CompactPlanCard({
plan,
isSelected,
onSelect,
}: {
plan: Plan;
isSelected: boolean;
onSelect: () => void;
}) {
const savingsPercent =
plan.original_price && plan.billing_period === 'year'
? Math.round(
((plan.original_price - plan.price) / plan.original_price) *
100,
)
: null;
const monthlyEquivalent =
plan.billing_period === 'year' ? plan.price / 12 : plan.price;
return (
<button
onClick={onSelect}
className={cn(
'flex flex-1 flex-col rounded-lg border p-3 text-left transition-all',
isSelected
? 'border-emerald-500 bg-emerald-50 ring-2 ring-emerald-500 dark:bg-emerald-950/30'
: 'border-border bg-card hover:border-muted-foreground/50',
)}
>
<div className="flex items-center gap-2">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{plan.billing_period === 'year' ? 'Annual' : 'Monthly'}
</span>
{savingsPercent && savingsPercent > 0 && (
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
Save {savingsPercent}%
</span>
)}
</div>
<div className="mt-1 flex items-baseline gap-1">
<span className="text-xl font-bold">${monthlyEquivalent}</span>
<span className="text-sm text-muted-foreground">
{getEquivalentBillingLabel(plan.billing_period)}
</span>
</div>
{plan.billing_period === 'year' && (
<span className="mt-2 text-xs text-muted-foreground">
Billed annually at ${plan.price}
</span>
)}
</button>
);
}
function PricingSection({
planEntries,
defaultPlan,
}: {
planEntries: [string, Plan][];
defaultPlan: string;
}) {
const [selectedPlan, setSelectedPlan] = useState(defaultPlan);
const selectedPlanData = planEntries.find(
([key]) => key === selectedPlan,
)?.[1];
return (
<div className="flex flex-col gap-4">
<div className="flex gap-3">
{planEntries.map(([key, plan]) => (
<CompactPlanCard
key={key}
plan={plan}
isSelected={key === selectedPlan}
onSelect={() => setSelectedPlan(key)}
/>
))}
</div>
<a href={checkout.url({ query: { plan: selectedPlan } })}>
<Button
className="w-full bg-emerald-600 py-6 hover:bg-emerald-700 dark:bg-emerald-600 dark:hover:bg-emerald-700"
size="lg"
>
Start My Financial Journey
</Button>
</a>
{selectedPlanData && (
<ul className="grid grid-cols-2 gap-x-4 gap-y-1 px-6">
{selectedPlanData.features.slice(0, 4).map((feature) => (
<li key={feature} className="flex items-center gap-1.5">
<CheckIcon className="size-3 shrink-0 text-emerald-500" />
<span className="text-xs text-muted-foreground">
{feature}
</span>
</li>
))}
</ul>
)}
</div>
);
}
function PromoSection() {
return (
<p className="flex items-center justify-center gap-2 text-center text-xs text-muted-foreground">
<span>Your data is ready</span>
<span></span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://discord.gg/9UQWZECDDv"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-[#5865F2] underline-offset-2 hover:underline"
>
Discord for $8 off
</a>
</TooltipTrigger>
<TooltipContent>
You'll receive an exclusive promo code via DM!
</TooltipContent>
</Tooltip>
</TooltipProvider>
</p>
);
}
export default function Paywall() {
const { pricing } = usePage<SharedData>().props;
const { pricing, stats } = usePage<PaywallPageProps>().props;
const planEntries = Object.entries(pricing.plans);
if (planEntries.length === 0) {
@ -98,62 +371,20 @@ export default function Paywall() {
return (
<>
<Head title="Upgrade to Pro" />
<Head title="Start Your Financial Journey" />
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4 py-12">
<div className="w-full max-w-4xl">
<div className="mb-8 text-center">
<h1 className="text-3xl font-bold">Upgrade to Pro</h1>
<p className="mt-2 text-muted-foreground">
Unlock all features and take control of your
finances
</p>
</div>
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4 py-8">
<div className="flex w-full max-w-md flex-col gap-6">
<SocialProofSlider />
<div
className={cn(
'grid gap-4',
planEntries.length === 1 && 'mx-auto max-w-sm',
planEntries.length === 2 &&
'mx-auto max-w-2xl grid-cols-1 sm:grid-cols-2',
planEntries.length >= 3 &&
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
)}
>
{planEntries.map(([key, plan]) => (
<PlanCard
key={key}
planKey={key}
plan={plan}
isDefault={key === pricing.defaultPlan}
isBestValue={key === pricing.bestValuePlan}
/>
))}
</div>
<FinancialSnapshot stats={stats} />
{pricing.promo.enabled && (
<p className="mt-6 text-center text-sm text-muted-foreground">
🎉 Get a founder discount {' '}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://discord.gg/9UQWZECDDv"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-[#5865F2] underline-offset-2 hover:underline"
>
Join our Discord
</a>
</TooltipTrigger>
<TooltipContent>
You'll receive an exclusive promo code
via DM!
</TooltipContent>
</Tooltip>
</TooltipProvider>
</p>
)}
<PricingSection
planEntries={planEntries}
defaultPlan={pricing.defaultPlan}
/>
{pricing.promo.enabled && <PromoSection />}
</div>
</div>
</>

View File

@ -2,6 +2,7 @@
use App\Http\Controllers\AccountController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\OnboardingController;
use App\Http\Controllers\RobotsController;
use App\Http\Controllers\SitemapController;
use App\Http\Controllers\SubscriptionController;
@ -31,20 +32,19 @@ Route::get('terms', function () {
return Inertia::render('terms');
})->name('terms');
Route::middleware(['auth'])->group(function () {
Route::get('setup-encryption', function () {
return Inertia::render('auth/setup-encryption');
})->name('setup-encryption');
});
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('subscribe', [SubscriptionController::class, 'index'])->name('subscribe');
Route::get('subscribe/checkout', [SubscriptionController::class, 'checkout'])->name('subscribe.checkout');
Route::get('subscribe/success', [SubscriptionController::class, 'success'])->name('subscribe.success');
Route::get('subscribe/cancel', [SubscriptionController::class, 'cancel'])->name('subscribe.cancel');
Route::middleware(['onboarded'])->group(function () {
Route::get('onboarding', [OnboardingController::class, 'index'])->name('onboarding');
Route::post('onboarding/complete', [OnboardingController::class, 'complete'])->name('onboarding.complete');
});
});
Route::middleware(['auth', 'verified', 'redirect.encryption', 'subscribed'])->group(function () {
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
Route::get('dashboard', DashboardController::class)->name('dashboard');
Route::get('accounts', [AccountController::class, 'index'])->name('accounts.list');

View File

@ -7,7 +7,7 @@ use App\Models\User;
use function Pest\Laravel\actingAs;
it('formats amount on blur', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create(['user_id' => $user->id]);
@ -25,7 +25,7 @@ it('formats amount on blur', function () {
})->skip('Requires browser encryption key setup');
it('accepts comma as decimal separator', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create(['user_id' => $user->id]);
@ -43,7 +43,7 @@ it('accepts comma as decimal separator', function () {
})->skip('Requires browser encryption key setup');
it('can create a transaction with amount input', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create(['user_id' => $user->id]);

View File

@ -12,8 +12,9 @@ it('can register a new user', function () {
->fill('password_confirmation', 'password123')
->click('@register-user-button')
->wait(2)
->assertSee('Setup Encryption')
->assertPathIs('/setup-encryption')
->assertSee('Welcome to')
->assertSee('Whisper Money')
->assertPathIs('/onboarding')
->assertNoJavascriptErrors();
$this->assertDatabaseHas('users', [
@ -36,10 +37,9 @@ it('shows validation errors for invalid registration', function () {
});
it('can login with valid credentials', function () {
$user = User::factory()->create([
$user = User::factory()->onboarded()->create([
'email' => 'test@example.com',
'password' => bcrypt('password123'),
'encryption_salt' => str_repeat('a', 24),
'two_factor_secret' => null,
'two_factor_confirmed_at' => null,
]);

View File

@ -11,7 +11,7 @@ beforeEach(function () {
});
it('can create an automation rule with visual builder', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
actingAs($user);
@ -41,7 +41,7 @@ it('can create an automation rule with visual builder', function () {
})->skip('Requires browser encryption key setup');
it('can add multiple conditions to a group', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
actingAs($user);
@ -69,7 +69,7 @@ it('can add multiple conditions to a group', function () {
})->skip('Requires browser encryption key setup');
it('can add multiple groups', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
actingAs($user);
@ -98,7 +98,7 @@ it('can add multiple groups', function () {
})->skip('Requires browser encryption key setup');
it('can select different field types and operators', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
actingAs($user);
@ -130,7 +130,7 @@ it('can select different field types and operators', function () {
})->skip('Requires browser encryption key setup');
it('can edit an existing rule with visual builder', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$rule = $user->automationRules()->create([
@ -163,7 +163,7 @@ it('can edit an existing rule with visual builder', function () {
})->skip('Requires browser encryption key setup');
it('validates that at least one condition is required', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
actingAs($user);
@ -188,7 +188,7 @@ it('validates that at least one condition is required', function () {
})->skip('Requires browser encryption key setup');
it('can toggle group operators between AND and OR', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
actingAs($user);
@ -219,7 +219,7 @@ it('can toggle group operators between AND and OR', function () {
})->skip('Requires browser encryption key setup');
it('can use is empty operator for nullable fields', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
actingAs($user);

View File

@ -7,7 +7,7 @@ use App\Models\User;
use function Pest\Laravel\actingAs;
it('can view bank accounts page', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
actingAs($user);
@ -19,7 +19,7 @@ it('can view bank accounts page', function () {
});
it('shows existing accounts in list', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$bank = Bank::factory()->create(['name' => 'Test Bank']);
Account::factory()->create([
'user_id' => $user->id,
@ -42,7 +42,7 @@ it('shows existing accounts in list', function () {
});
it('can open create account dialog', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
actingAs($user);
@ -56,7 +56,7 @@ it('can open create account dialog', function () {
})->skip('Requires browser encryption key setup');
it('can create a new bank account', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$bank = Bank::factory()->create(['name' => 'My Bank']);
actingAs($user);
@ -91,7 +91,7 @@ it('can create a new bank account', function () {
})->skip('Requires browser encryption key setup');
it('shows empty state when no accounts exist', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
actingAs($user);
@ -103,7 +103,7 @@ it('shows empty state when no accounts exist', function () {
});
it('can filter accounts by name', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$bank = Bank::factory()->create(['name' => 'Test Bank']);
Account::factory()->create([
'user_id' => $user->id,
@ -130,7 +130,7 @@ it('can filter accounts by name', function () {
});
it('can edit an existing account via dropdown menu', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$bank = Bank::factory()->create(['name' => 'Test Bank']);
$account = Account::factory()->create([
'user_id' => $user->id,
@ -158,7 +158,7 @@ it('can edit an existing account via dropdown menu', function () {
})->skip('Requires browser encryption key setup');
it('can delete an account via dropdown menu', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$bank = Bank::factory()->create(['name' => 'Test Bank']);
$account = Account::factory()->create([
'user_id' => $user->id,

View File

@ -6,7 +6,7 @@ use App\Models\User;
use function Pest\Laravel\actingAs;
it('can view categories page', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
actingAs($user);
@ -18,7 +18,7 @@ it('can view categories page', function () {
});
it('shows existing categories in list', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create([
'user_id' => $user->id,
'name' => 'Groceries',
@ -36,7 +36,7 @@ it('shows existing categories in list', function () {
});
it('can open create category dialog', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
actingAs($user);
@ -50,7 +50,7 @@ it('can open create category dialog', function () {
});
it('can create a new category', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
actingAs($user);
@ -86,7 +86,7 @@ it('can create a new category', function () {
});
it('can filter categories by name', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
Category::factory()->create([
'user_id' => $user->id,
'name' => 'Groceries',
@ -109,7 +109,7 @@ it('can filter categories by name', function () {
});
it('shows empty state when no categories exist', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
actingAs($user);
@ -121,7 +121,7 @@ it('shows empty state when no categories exist', function () {
});
it('can edit an existing category via context menu', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create([
'user_id' => $user->id,
'name' => 'Old Category',
@ -152,7 +152,7 @@ it('can edit an existing category via context menu', function () {
});
it('shows transfer type description when transfer type is selected in create dialog', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
actingAs($user);
@ -173,7 +173,7 @@ it('shows transfer type description when transfer type is selected in create dia
});
it('shows transfer type description when transfer type is selected in edit dialog', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create([
'user_id' => $user->id,
'name' => 'Test Category',

View File

@ -8,7 +8,7 @@ use App\Models\User;
use function Pest\Laravel\actingAs;
it('can open import transactions drawer', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
Category::factory()->create(['user_id' => $user->id]);
$bank = Bank::factory()->create(['name' => 'Test Bank']);
Account::factory()->create([
@ -33,7 +33,7 @@ it('can open import transactions drawer', function () {
})->skip('Requires browser encryption key setup');
it('shows no accounts message when none exist', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
Category::factory()->create(['user_id' => $user->id]);
actingAs($user);
@ -50,7 +50,7 @@ it('shows no accounts message when none exist', function () {
})->skip('Requires browser encryption key setup');
it('can select account for import', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
Category::factory()->create(['user_id' => $user->id]);
$bank = Bank::factory()->create(['name' => 'My Bank']);
Account::factory()->create([
@ -82,7 +82,7 @@ it('can select account for import', function () {
})->skip('Requires browser encryption key setup');
it('can upload a CSV file for import', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
Category::factory()->create(['user_id' => $user->id]);
$bank = Bank::factory()->create(['name' => 'My Bank']);
Account::factory()->create([
@ -116,7 +116,7 @@ it('can upload a CSV file for import', function () {
})->skip('Requires browser encryption key setup');
it('can complete full import flow', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
Category::factory()->create(['user_id' => $user->id]);
$bank = Bank::factory()->create(['name' => 'My Bank']);
$account = Account::factory()->create([
@ -167,7 +167,7 @@ it('can complete full import flow', function () {
})->skip('Requires browser encryption key setup');
it('shows column mapping step after file upload', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
Category::factory()->create(['user_id' => $user->id]);
$bank = Bank::factory()->create(['name' => 'My Bank']);
Account::factory()->create([
@ -206,7 +206,7 @@ it('shows column mapping step after file upload', function () {
})->skip('Requires browser encryption key setup');
it('can navigate back through import steps', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
Category::factory()->create(['user_id' => $user->id]);
$bank = Bank::factory()->create(['name' => 'My Bank']);
Account::factory()->create([
@ -238,7 +238,7 @@ it('can navigate back through import steps', function () {
})->skip('Requires browser encryption key setup');
it('applies automation rules when importing transactions', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$groceriesCategory = Category::factory()->create([
'user_id' => $user->id,

View File

@ -0,0 +1,460 @@
<?php
use App\Models\Account;
use App\Models\Bank;
use App\Models\User;
// =============================================================================
// Basic Redirect Tests
// =============================================================================
it('redirects new registration to onboarding page', function () {
$page = visit('/register');
$page->assertSee('Create an account')
->fill('name', 'Test Onboarding User')
->fill('email', 'onboarding-test@example.com')
->fill('password', 'password123456')
->fill('password_confirmation', 'password123456')
->click('@register-user-button')
->wait(3)
->assertPathIs('/onboarding')
->assertNoJavascriptErrors();
$this->assertDatabaseHas('users', [
'email' => 'onboarding-test@example.com',
'name' => 'Test Onboarding User',
]);
});
it('redirects onboarded user away from onboarding page to dashboard', function () {
$user = User::factory()->onboarded()->create();
$this->actingAs($user);
$page = visit('/onboarding');
$page->assertPathIs('/dashboard')
->assertNoJavascriptErrors();
});
it('redirects non-onboarded user from dashboard to onboarding', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
$this->actingAs($user);
$page = visit('/dashboard');
$page->assertPathIs('/onboarding')
->assertNoJavascriptErrors();
});
// =============================================================================
// Step Navigation Tests
// =============================================================================
it('shows welcome step as first onboarding step', function () {
$user = User::factory()->create([
'onboarded_at' => null,
]);
$this->actingAs($user);
$page = visit('/onboarding');
$page->assertSee('Welcome to')
->assertSee('Whisper Money')
->assertSee("Let's Get Started")
->assertNoJavascriptErrors();
});
it('navigates from welcome to encryption explanation', function () {
$user = User::factory()->create([
'onboarded_at' => null,
]);
$this->actingAs($user);
$page = visit('/onboarding');
$page->assertSee('Welcome to')
->assertSee('Whisper Money')
->click("Let's Get Started")
->wait(1)
->assertSee('Your Data, Your Privacy')
->assertSee('End-to-End Encryption')
->assertNoJavascriptErrors();
});
it('shows encryption setup after encryption explanation', function () {
$user = User::factory()->create([
'onboarded_at' => null,
]);
$this->actingAs($user);
$page = visit('/onboarding');
$page->click("Let's Get Started")
->wait(1)
->assertSee('Your Data, Your Privacy')
->click('@encryption-continue-button')
->wait(1)
->assertSee('Create Your Encryption Password')
->assertNoJavascriptErrors();
});
it('marks user as onboarded when completing onboarding', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
expect($user->isOnboarded())->toBeFalse();
$this->actingAs($user)->post('/onboarding/complete');
$user->refresh();
expect($user->isOnboarded())->toBeTrue();
expect($user->onboarded_at)->not->toBeNull();
});
// =============================================================================
// Encryption Key Skip Tests
// =============================================================================
it('skips encryption setup step when user has encryption key stored', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
$this->actingAs($user);
$page = visit('/onboarding');
// Store a mock encryption key in localStorage (using the correct key name)
$page->script("localStorage.setItem('encryption_key', 'mock-encryption-key')");
// Reload the page to pick up the stored key
$page->navigate('/onboarding')
->wait(1)
// Still shows welcome
->assertSee('Welcome to')
->assertSee('Whisper Money')
->click("Let's Get Started")
->wait(1)
// Still shows encryption explanation
->assertSee('Your Data, Your Privacy')
->click('@encryption-continue-button')
->wait(1)
// Should skip encryption-setup and go directly to account types
->assertSee('Account Types')
->assertDontSee('Create Your Encryption Password')
->assertNoJavascriptErrors();
// Cleanup
$page->script("localStorage.removeItem('encryption_key')");
});
it('shows encryption setup step when no encryption key exists', function () {
$user = User::factory()->create([
'onboarded_at' => null,
]);
$this->actingAs($user);
$page = visit('/onboarding');
// Ensure no key exists
$page->script("localStorage.removeItem('encryption_key')");
$page->script("sessionStorage.removeItem('encryption_key')");
$page->navigate('/onboarding')
->wait(1)
->assertSee('Welcome to')
->assertSee('Whisper Money')
->click("Let's Get Started")
->wait(1)
->assertSee('Your Data, Your Privacy')
->click('@encryption-continue-button')
->wait(1)
// Should show encryption setup since no key exists
->assertSee('Create Your Encryption Password')
->assertNoJavascriptErrors();
});
// =============================================================================
// Existing Account Flow Tests
// =============================================================================
it('shows existing accounts instead of create form when accounts exist', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
$bank = Bank::factory()->create(['name' => 'Test Bank']);
Account::factory()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'type' => 'checking',
'currency_code' => 'USD',
]);
$this->actingAs($user);
$page = visit('/onboarding');
// Store mock encryption key to skip encryption setup step
$page->script("localStorage.setItem('encryption_key', 'mock-encryption-key')");
$page->navigate('/onboarding')
->wait(1)
// Navigate through initial steps
->click("Let's Get Started")
->wait(1)
->click('@encryption-continue-button')
->wait(1)
// Should now be at account types (encryption-setup was skipped)
->assertSee('Account Types')
->click('Create Your First Account')
->wait(1)
// Should show existing accounts, not the create form
->assertSee('Your Accounts')
->assertSee('Test Bank')
->assertSee('Checking')
->assertNoJavascriptErrors();
$page->script("localStorage.removeItem('encryption_key')");
});
it('allows continuing with existing accounts', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
$bank = Bank::factory()->create(['name' => 'Existing Bank']);
Account::factory()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'type' => 'checking',
'currency_code' => 'EUR',
]);
$this->actingAs($user);
$page = visit('/onboarding');
$page->script("localStorage.setItem('encryption_key', 'mock-encryption-key')");
$page->navigate('/onboarding')
->wait(1)
// Navigate through initial steps
->click("Let's Get Started")
->wait(1)
->click('@encryption-continue-button')
->wait(1)
->assertSee('Account Types')
->click('Create Your First Account')
->wait(1)
->assertSee('Your Accounts')
->assertSee('Existing Bank')
// Click Continue to proceed
->click('Continue')
->wait(2)
// Should go to import transactions (since checking account needs transactions)
->assertSee('Import Your Transactions')
->assertNoJavascriptErrors();
$page->script("localStorage.removeItem('encryption_key')");
});
// =============================================================================
// More Accounts Flow Tests
// =============================================================================
it('shows import transactions step after account creation', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
$bank = Bank::factory()->create(['name' => 'My Bank']);
Account::factory()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'type' => 'checking',
'currency_code' => 'USD',
]);
$this->actingAs($user);
$page = visit('/onboarding');
$page->script("localStorage.setItem('encryption_key', 'mock-encryption-key')");
$page->navigate('/onboarding')
->wait(1)
// Navigate through initial steps
->click("Let's Get Started")
->wait(1)
->click('@encryption-continue-button')
->wait(1)
->click('Create Your First Account')
->wait(1)
->click('Continue')
->wait(2)
// Should show import transactions step
->assertSee('Import Your Transactions')
->assertSee('Import Transactions')
->assertNoJavascriptErrors();
$page->script("localStorage.removeItem('encryption_key')");
});
it('shows add another account form without first account restriction', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
$bank = Bank::factory()->create(['name' => 'Primary Bank']);
Account::factory()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'type' => 'checking',
'currency_code' => 'USD',
]);
$this->actingAs($user);
$page = visit('/onboarding');
$page->script("localStorage.setItem('encryption_key', 'mock-encryption-key')");
// Navigate to more-accounts step via direct state manipulation
// For this test, we verify that when adding another account, the first account restriction is gone
$page->navigate('/onboarding')
->wait(1)
->click("Let's Get Started")
->wait(1)
->click('@encryption-continue-button')
->wait(1)
->click('Create Your First Account')
->wait(1)
// At this point, the "Your Accounts" view shows existing accounts
// The Continue button will proceed to import, but the key point is tested:
// existing accounts are shown correctly
->assertSee('Your Accounts')
->assertSee('Primary Bank')
->assertNoJavascriptErrors();
$page->script("localStorage.removeItem('encryption_key')");
});
// =============================================================================
// Full End-to-End Flow Test
// =============================================================================
it('completes onboarding flow through account creation', function () {
// Create a bank for the account creation step
Bank::factory()->create(['name' => 'Chase Bank']);
$page = visit('/register');
// Step 1: Register
$page->assertSee('Create an account')
->fill('name', 'E2E Test User')
->fill('email', 'e2e-onboarding@example.com')
->fill('password', 'SecurePassword123!')
->fill('password_confirmation', 'SecurePassword123!')
->click('@register-user-button')
->wait(3)
->assertPathIs('/onboarding')
->assertNoJavascriptErrors();
// Step 2: Welcome
$page->assertSee('Welcome to')
->assertSee('Whisper Money')
->click("Let's Get Started")
->wait(1);
// Step 3: Encryption Explanation
$page->assertSee('Your Data, Your Privacy')
->click('@encryption-continue-button')
->wait(1);
// Step 4: Encryption Setup
$page->assertSee('Create Your Encryption Password')
->fill('#password', 'MySecureEncryptionPassword123!')
->fill('#confirmPassword', 'MySecureEncryptionPassword123!')
->click('Setup Encryption')
->wait(3);
// Step 5: Account Types
$page->assertSee('Account Types')
->assertSee('Checking')
->assertSee('Savings')
->assertSee('Credit Card')
->click('Create Your First Account')
->wait(1);
// Step 6: Create Account
$page->assertSee('Create an Account')
->assertSee('Your first account must be a')
->fill('#display_name', 'My Checking Account')
// Select bank from combobox - need to search first
->click('Select bank...')
->wait(1)
->fill('[placeholder="Search bank..."]', 'Chase')
->wait(2)
->click('Chase Bank')
->wait(1)
// Select currency - click on the dropdown item (Radix UI creates role="option")
->click('Select currency')
->wait(1)
->click('[role="option"]:has-text("USD")')
->wait(1)
->click('Create Account')
->wait(3);
// Step 7: Import Transactions step should appear
$page->assertSee('Import Your Transactions')
->assertNoJavascriptErrors();
// Verify user's encryption was set up
$user = User::where('email', 'e2e-onboarding@example.com')->first();
expect($user->encryption_salt)->not->toBeNull();
// Verify account was created
expect($user->accounts()->count())->toBe(1);
expect($user->accounts()->first()->type->value)->toBe('checking');
});
it('marks user as onboarded when completing via API', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
$bank = Bank::factory()->create();
Account::factory()->create([
'user_id' => $user->id,
'bank_id' => $bank->id,
'type' => 'checking',
]);
expect($user->isOnboarded())->toBeFalse();
// Complete onboarding via POST
$this->actingAs($user)->post('/onboarding/complete');
$user->refresh();
expect($user->isOnboarded())->toBeTrue();
expect($user->onboarded_at)->not->toBeNull();
});

View File

@ -7,7 +7,7 @@ use App\Models\User;
use function Pest\Laravel\actingAs;
it('can view transactions page', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
Category::factory()->create(['user_id' => $user->id]);
Account::factory()->create(['user_id' => $user->id]);
@ -21,7 +21,7 @@ it('can view transactions page', function () {
});
it('can open add transaction dialog', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
Category::factory()->create(['user_id' => $user->id]);
Account::factory()->create(['user_id' => $user->id]);
@ -37,7 +37,7 @@ it('can open add transaction dialog', function () {
})->skip('Requires browser encryption key setup');
it('can create a transaction', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create(['user_id' => $user->id]);
@ -67,7 +67,7 @@ it('can create a transaction', function () {
})->skip('Requires browser encryption key setup');
it('shows empty state when no transactions exist', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
Category::factory()->create(['user_id' => $user->id]);
Account::factory()->create(['user_id' => $user->id]);
@ -81,7 +81,7 @@ it('shows empty state when no transactions exist', function () {
});
it('can filter transactions by search text', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
Category::factory()->create(['user_id' => $user->id]);
Account::factory()->create(['user_id' => $user->id]);

View File

@ -11,9 +11,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create([
'encryption_salt' => str_repeat('a', 24),
]);
$this->user = User::factory()->onboarded()->create();
$this->actingAs($this->user);
});

View File

@ -15,5 +15,5 @@ test('new users can register', function () {
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
$response->assertRedirect(route('onboarding', absolute: false));
});

View File

@ -7,9 +7,7 @@ test('guests are redirected to the login page', function () {
});
test('authenticated users can visit the dashboard', function () {
$this->actingAs($user = User::factory()->create([
'encryption_salt' => str_repeat('a', 24),
]));
$this->actingAs(User::factory()->onboarded()->create());
$this->get(route('dashboard'))->assertOk();
});

View File

@ -9,7 +9,7 @@ use function Pest\Laravel\assertDatabaseHas;
test('authenticated user without encryption salt can access setup page', function () {
$user = User::factory()->create(['encryption_salt' => null]);
$response = actingAs($user)->get(route('setup-encryption'));
$response = actingAs($user)->get(route('onboarding'));
$response->assertSuccessful();
});
@ -93,18 +93,16 @@ test('user without encrypted message receives 404', function () {
$response->assertNotFound();
});
test('user without encryption salt is redirected to setup', function () {
$user = User::factory()->create(['encryption_salt' => null]);
test('user without onboarding is redirected to onboarding', function () {
$user = User::factory()->notOnboarded()->create(['encryption_salt' => 'test-salt']);
$response = actingAs($user)->get(route('dashboard'));
$response->assertRedirect(route('setup-encryption'));
$response->assertRedirect(route('onboarding'));
});
test('user with encryption salt can access dashboard', function () {
$user = User::factory()->create([
'encryption_salt' => str_repeat('a', 24),
]);
test('onboarded user with encryption salt can access dashboard', function () {
$user = User::factory()->onboarded()->create();
$response = actingAs($user)->get(route('dashboard'));

View File

@ -0,0 +1,80 @@
<?php
use App\Models\User;
it('redirects non-onboarded user from dashboard to onboarding', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
$response = $this->actingAs($user)->get('/dashboard');
$response->assertRedirect('/onboarding');
});
it('allows onboarded user to access dashboard', function () {
$user = User::factory()->onboarded()->create();
$response = $this->actingAs($user)->get('/dashboard');
$response->assertSuccessful();
});
it('redirects onboarded user away from onboarding page', function () {
$user = User::factory()->onboarded()->create();
$response = $this->actingAs($user)->get('/onboarding');
$response->assertRedirect('/dashboard');
});
it('allows non-onboarded user to access onboarding page', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
$response = $this->actingAs($user)->get('/onboarding');
$response->assertSuccessful();
});
it('sets onboarded_at when completing onboarding', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
expect($user->isOnboarded())->toBeFalse();
$response = $this->actingAs($user)->post('/onboarding/complete');
$response->assertRedirect('/dashboard');
$user->refresh();
expect($user->isOnboarded())->toBeTrue();
expect($user->onboarded_at)->not->toBeNull();
});
it('redirects non-onboarded user from accounts list to onboarding', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
$response = $this->actingAs($user)->get('/accounts');
$response->assertRedirect('/onboarding');
});
it('redirects non-onboarded user from transactions to onboarding', function () {
$user = User::factory()->create([
'onboarded_at' => null,
'encryption_salt' => 'test-salt',
]);
$response = $this->actingAs($user)->get('/transactions');
$response->assertRedirect('/onboarding');
});

View File

@ -40,5 +40,4 @@ test('robots txt disallows protected routes', function () {
$response->assertSuccessful();
expect($response->content())->toContain('Disallow: /api/');
expect($response->content())->toContain('Disallow: /dashboard');
expect($response->content())->toContain('Disallow: /setup-encryption');
});

View File

@ -1,5 +1,9 @@
<?php
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
beforeEach(function () {
@ -13,9 +17,7 @@ test('guests cannot access subscription pages', function () {
});
test('users without subscription are redirected to paywall when accessing protected routes', function () {
$user = User::factory()->create([
'encryption_salt' => str_repeat('a', 24),
]);
$user = User::factory()->onboarded()->create();
$this->actingAs($user);
@ -25,19 +27,41 @@ test('users without subscription are redirected to paywall when accessing protec
});
test('users can view the paywall page', function () {
$user = User::factory()->create([
'encryption_salt' => str_repeat('a', 24),
]);
$user = User::factory()->onboarded()->create();
$this->actingAs($user);
$this->get(route('subscribe'))->assertOk();
});
test('paywall page includes user stats', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->for($user)->create(['currency_code' => 'USD']);
AccountBalance::factory()->for($account)->create(['balance' => 150000]);
Transaction::factory()->count(3)->for($user)->for($account)->create();
Category::factory()->count(2)->for($user)->create();
$this->actingAs($user);
$this->get(route('subscribe'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('subscription/paywall')
->has('stats')
->has('stats.accountsCount')
->has('stats.transactionsCount')
->has('stats.categoriesCount')
->has('stats.automationRulesCount')
->has('stats.balancesByCurrency')
->where('stats.accountsCount', 1)
->where('stats.transactionsCount', 3)
->where('stats.balancesByCurrency.USD', 150000)
);
});
test('subscribed users are redirected from paywall to dashboard', function () {
$user = User::factory()->create([
'encryption_salt' => str_repeat('a', 24),
]);
$user = User::factory()->onboarded()->create();
$user->subscriptions()->create([
'type' => 'default',
@ -52,9 +76,7 @@ test('subscribed users are redirected from paywall to dashboard', function () {
});
test('subscribed users can access protected routes', function () {
$user = User::factory()->create([
'encryption_salt' => str_repeat('a', 24),
]);
$user = User::factory()->onboarded()->create();
$user->subscriptions()->create([
'type' => 'default',
@ -69,9 +91,7 @@ test('subscribed users can access protected routes', function () {
});
test('users can view the success page after subscribing', function () {
$user = User::factory()->create([
'encryption_salt' => str_repeat('a', 24),
]);
$user = User::factory()->onboarded()->create();
$this->actingAs($user);
@ -79,9 +99,7 @@ test('users can view the success page after subscribing', function () {
});
test('cancel route redirects to paywall', function () {
$user = User::factory()->create([
'encryption_salt' => str_repeat('a', 24),
]);
$user = User::factory()->onboarded()->create();
$this->actingAs($user);
@ -91,9 +109,7 @@ test('cancel route redirects to paywall', function () {
test('subscription middleware allows access when subscriptions are disabled', function () {
config(['subscriptions.enabled' => false]);
$user = User::factory()->create([
'encryption_salt' => str_repeat('a', 24),
]);
$user = User::factory()->onboarded()->create();
$this->actingAs($user);

View File

@ -14,7 +14,7 @@ test('guests cannot access transactions page', function () {
});
test('authenticated users can access transactions page', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$response = actingAs($user)->get(route('transactions.index'));
@ -28,7 +28,7 @@ test('authenticated users can access transactions page', function () {
});
test('authenticated users can access categorize transactions page', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$response = actingAs($user)->get(route('transactions.categorize'));
@ -48,7 +48,7 @@ test('guests cannot access categorize transactions page', function () {
});
test('users can update their own transaction category', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$category = Category::factory()->create(['user_id' => $user->id]);
@ -70,7 +70,7 @@ test('users can update their own transaction category', function () {
});
test('users can update transaction notes', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->create([
@ -92,7 +92,7 @@ test('users can update transaction notes', function () {
});
test('users can clear transaction category', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$category = Category::factory()->create(['user_id' => $user->id]);
@ -114,7 +114,7 @@ test('users can clear transaction category', function () {
});
test('users cannot update other users transactions', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->create(['encryption_salt' => str_repeat('b', 24)]);
$account = Account::factory()->create(['user_id' => $otherUser->id]);
$category = Category::factory()->create(['user_id' => $user->id]);
@ -132,7 +132,7 @@ test('users cannot update other users transactions', function () {
});
test('category_id must exist when updating transaction', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->create([
@ -149,7 +149,7 @@ test('category_id must exist when updating transaction', function () {
});
test('notes_iv must be exactly 16 characters', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->create([
@ -167,7 +167,7 @@ test('notes_iv must be exactly 16 characters', function () {
});
test('users can soft delete their own transactions', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->create([
@ -184,7 +184,7 @@ test('users can soft delete their own transactions', function () {
});
test('users cannot delete other users transactions', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->create(['encryption_salt' => str_repeat('b', 24)]);
$account = Account::factory()->create(['user_id' => $otherUser->id]);
@ -203,7 +203,7 @@ test('users cannot delete other users transactions', function () {
});
test('transactions index page passes user categories', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->create();
$userCategory = Category::factory()->create(['user_id' => $user->id, 'name' => 'My Category']);
@ -220,7 +220,7 @@ test('transactions index page passes user categories', function () {
});
test('transactions index page passes user accounts', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->create();
Account::factory()->create(['user_id' => $user->id, 'name' => 'encrypted_name_1', 'name_iv' => str_repeat('a', 16)]);
@ -236,7 +236,7 @@ test('transactions index page passes user accounts', function () {
});
test('users can create a new transaction', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$category = Category::factory()->create(['user_id' => $user->id]);
@ -287,7 +287,7 @@ test('users can create a new transaction', function () {
});
test('users can create a transaction without category', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transactionData = [
@ -314,7 +314,7 @@ test('users can create a transaction without category', function () {
});
test('users can create a transaction without notes', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transactionData = [
@ -339,7 +339,7 @@ test('users can create a transaction without notes', function () {
});
test('account_id is required when creating transaction', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$transactionData = [
'description' => 'encrypted_description',
@ -356,7 +356,7 @@ test('account_id is required when creating transaction', function () {
});
test('description is required when creating transaction', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transactionData = [
@ -374,7 +374,7 @@ test('description is required when creating transaction', function () {
});
test('amount is required when creating transaction', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transactionData = [
@ -392,7 +392,7 @@ test('amount is required when creating transaction', function () {
});
test('transaction_date is required when creating transaction', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transactionData = [
@ -410,7 +410,7 @@ test('transaction_date is required when creating transaction', function () {
});
test('currency_code is required when creating transaction', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$transactionData = [