diff --git a/.env.example b/.env.example index 98dfb51d..741521c4 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php new file mode 100644 index 00000000..6e080482 --- /dev/null +++ b/app/Http/Controllers/OnboardingController.php @@ -0,0 +1,42 @@ +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'); + } +} diff --git a/app/Http/Controllers/RobotsController.php b/app/Http/Controllers/RobotsController.php index ac0abe23..9a6199b0 100644 --- a/app/Http/Controllers/RobotsController.php +++ b/app/Http/Controllers/RobotsController.php @@ -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"; diff --git a/app/Http/Controllers/Settings/AccountController.php b/app/Http/Controllers/Settings/AccountController.php index e88158ed..cb33ef74 100644 --- a/app/Http/Controllers/Settings/AccountController.php +++ b/app/Http/Controllers/Settings/AccountController.php @@ -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'); } diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index dbc2c596..7de78a78 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -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} + */ + 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) diff --git a/app/Http/Middleware/EnsureOnboardingComplete.php b/app/Http/Middleware/EnsureOnboardingComplete.php new file mode 100644 index 00000000..2d115c14 --- /dev/null +++ b/app/Http/Middleware/EnsureOnboardingComplete.php @@ -0,0 +1,43 @@ +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); + } +} diff --git a/app/Http/Middleware/RedirectToEncryptionSetup.php b/app/Http/Middleware/RedirectToEncryptionSetup.php deleted file mode 100644 index 0cadf9d2..00000000 --- a/app/Http/Middleware/RedirectToEncryptionSetup.php +++ /dev/null @@ -1,26 +0,0 @@ -user() && $request->user()->encryption_salt === null) { - if (! $request->routeIs('setup-encryption') && ! $request->is('api/encryption/setup')) { - return redirect()->route('setup-encryption'); - } - } - - return $next($request); - } -} diff --git a/app/Http/Responses/RegisterResponse.php b/app/Http/Responses/RegisterResponse.php new file mode 100644 index 00000000..7314fb55 --- /dev/null +++ b/app/Http/Responses/RegisterResponse.php @@ -0,0 +1,19 @@ +wantsJson()) { + return new JsonResponse('', 201); + } + + return redirect()->route('onboarding'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 413407c6..131f382d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b65..56f8e97d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); } /** diff --git a/bootstrap/app.php b/bootstrap/app.php index d91ca9e3..612cdf67 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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 { diff --git a/config/subscriptions.php b/config/subscriptions.php index 800a49d6..5207a78d 100644 --- a/config/subscriptions.php +++ b/config/subscriptions.php @@ -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', /* |-------------------------------------------------------------------------- diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 95dcffda..87ce11e2 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -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, + ]); + } } diff --git a/database/migrations/2025_12_10_142006_add_onboarded_at_to_users_table.php b/database/migrations/2025_12_10_142006_add_onboarded_at_to_users_table.php new file mode 100644 index 00000000..5468fba3 --- /dev/null +++ b/database/migrations/2025_12_10_142006_add_onboarded_at_to_users_table.php @@ -0,0 +1,28 @@ +timestamp('onboarded_at')->nullable()->after('encryption_salt'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('onboarded_at'); + }); + } +}; diff --git a/resources/js/components/accounts/account-form.tsx b/resources/js/components/accounts/account-form.tsx index 797b9666..ce3f5fad 100644 --- a/resources/js/components/accounts/account-form.tsx +++ b/resources/js/components/accounts/account-form.tsx @@ -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( - initialValues?.type ?? null, + initialValues?.type ?? forceAccountType ?? null, ); const [selectedCurrency, setSelectedCurrency] = useState(initialValues?.currencyCode ?? null); @@ -154,6 +159,7 @@ export function AccountForm({ initialValues, onChange }: AccountFormProps) { setPassword(e.target.value)} + placeholder="Enter a strong password" + disabled={processing} + autoComplete="new-password" + required + minLength={12} + /> +
+
+
+ {[1, 2, 3, 4].map((level) => ( +
+ ))} +
+ + {passwordStrength.label} + +
+ = 12 ? 'text-emerald-600' : 'text-muted-foreground'}`} + > + {password.length}/12 min + +
+
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm your password" + disabled={processing} + autoComplete="new-password" + required + /> + {confirmPassword && password === confirmPassword && ( +
+ + Passwords match +
+ )} +
+ +
+ + +

+ {storagePreference === 'session' + ? 'Your key will be cleared when you close the browser.' + : 'Your key will be stored until you log out.'} +

+
+ + {error && ( +
+ + {error} +
+ )} + + + + + ); +} + +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' }; +} diff --git a/resources/js/components/onboarding/step-header.tsx b/resources/js/components/onboarding/step-header.tsx new file mode 100644 index 00000000..e95ee844 --- /dev/null +++ b/resources/js/components/onboarding/step-header.tsx @@ -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 ( + <> +
+ +
+ +

+ +

+ {description} +

+ + ); +} diff --git a/resources/js/components/onboarding/step-import-balances.tsx b/resources/js/components/onboarding/step-import-balances.tsx new file mode 100644 index 00000000..79f15f2e --- /dev/null +++ b/resources/js/components/onboarding/step-import-balances.tsx @@ -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(null); + + async function handleSubmit(e: React.FormEvent) { + 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 ( +
+ + +
+
+
+ +
+
+

Balance Tracking

+

+ Perfect for investment portfolios and retirement + accounts +

+
+
+ +
    +
  • +
    + Update balances periodically to track growth +
  • +
  • +
    + Import balance history from CSV files +
  • +
  • +
    + View balance evolution over time +
  • +
+
+ +
+
+ + +
+ + {error && ( +
+ + {error} +
+ )} + + + +
+ ); +} diff --git a/resources/js/components/onboarding/step-import-transactions.tsx b/resources/js/components/onboarding/step-import-transactions.tsx new file mode 100644 index 00000000..1b095e66 --- /dev/null +++ b/resources/js/components/onboarding/step-import-transactions.tsx @@ -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 ( +
+ + +
+

+ How to Export from Your Bank: +

+
    +
  1. + + 1 + + Log in to your bank's website or app +
  2. +
  3. + + 2 + + Go to your account's transaction history +
  4. +
  5. + + 3 + + Look for "Export" or "Download" option +
  6. +
  7. + + 4 + + Download as CSV or Excel format +
  8. +
+
+ +
+ +
+

Supported formats

+

+ CSV, XLS, XLSX files +

+
+
+ +
+ + + {hasImported && ( + + )} +
+ + +
+ ); +} diff --git a/resources/js/components/onboarding/step-more-accounts.tsx b/resources/js/components/onboarding/step-more-accounts.tsx new file mode 100644 index 00000000..cf9cf3ff --- /dev/null +++ b/resources/js/components/onboarding/step-more-accounts.tsx @@ -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 ( +
+ + +
+

+ Your Accounts +

+
+ {createdAccounts.map((account) => ( +
+
+ +
+
+

{account.name}

+

+ {formatAccountType(account.type)} •{' '} + {account.currencyCode} +

+
+ +
+ ))} + {existingAccounts.map((account) => ( +
+
+ +
+
+

+ {account.bank?.name || 'Account'} +

+

+ {formatAccountType(account.type)} •{' '} + {account.currency_code} +

+
+ +
+ ))} +
+
+ +
+
+

Add More Accounts?

+

+ Track all your finances in one place — checking, + savings, credit cards, investments, and more. +

+ +
+
+ + +
+ ); +} diff --git a/resources/js/components/onboarding/step-smart-rules.tsx b/resources/js/components/onboarding/step-smart-rules.tsx new file mode 100644 index 00000000..34537741 --- /dev/null +++ b/resources/js/components/onboarding/step-smart-rules.tsx @@ -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 ( +
+ + +
+
+
+ +
+

Pattern Matching

+

+ Create rules like "If description contains 'AMAZON', + categorize as Shopping" +

+
+ +
+
+ +
+

Instant Application

+

+ Rules apply automatically when you import new + transactions +

+
+
+ +
+
+
+ +
+
+

+ Why No AI Auto-Categorization? +

+

+ Privacy comes first +

+
+
+ +
+
+
+ +
+
+

+ AI requires sending your data to external + servers +

+

+ This would break our end-to-end encryption + promise +

+
+
+ +
+
+ +
+
+

+ Your rules run entirely in your browser +

+

+ We never see your transaction descriptions +

+
+
+ +
+
+ +
+
+

+ You're in complete control +

+

+ Create, edit, and delete rules anytime +

+
+
+
+
+ + +
+ ); +} diff --git a/resources/js/components/onboarding/step-welcome.tsx b/resources/js/components/onboarding/step-welcome.tsx new file mode 100644 index 00000000..0fd1b221 --- /dev/null +++ b/resources/js/components/onboarding/step-welcome.tsx @@ -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 ( +
+ + +
+ + +

+ This will take less than 5 minutes +

+
+
+ ); +} diff --git a/resources/js/components/transactions/import-step-account.tsx b/resources/js/components/transactions/import-step-account.tsx index 221a4fd5..7eff32ce 100644 --- a/resources/js/components/transactions/import-step-account.tsx +++ b/resources/js/components/transactions/import-step-account.tsx @@ -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 (
diff --git a/resources/js/hooks/use-count-up.ts b/resources/js/hooks/use-count-up.ts new file mode 100644 index 00000000..aadf8430 --- /dev/null +++ b/resources/js/hooks/use-count-up.ts @@ -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; +} diff --git a/resources/js/hooks/use-onboarding-state.ts b/resources/js/hooks/use-onboarding-state.ts new file mode 100644 index 00000000..706c5c65 --- /dev/null +++ b/resources/js/hooks/use-onboarding-state.ts @@ -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(initialStep); + const [createdAccounts, setCreatedAccounts] = useState( + [], + ); + + // 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, + }; +} diff --git a/resources/js/layouts/onboarding-layout.tsx b/resources/js/layouts/onboarding-layout.tsx new file mode 100644 index 00000000..b30987b8 --- /dev/null +++ b/resources/js/layouts/onboarding-layout.tsx @@ -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) { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + setIsVisible(false); + const timer = setTimeout(() => setIsVisible(true), 50); + return () => clearTimeout(timer); + }, [stepKey]); + + return ( +
+
+ + +
+ {Array.from({ length: totalSteps }).map((_, index) => ( +
+ ))} +
+ +
+
+ +
+
+ {children} +
+
+
+ ); +} diff --git a/resources/js/lib/user-session-storage.ts b/resources/js/lib/user-session-storage.ts index fb29fe20..df946cb6 100644 --- a/resources/js/lib/user-session-storage.ts +++ b/resources/js/lib/user-session-storage.ts @@ -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 { 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 { 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 { 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 { - 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; } diff --git a/resources/js/pages/onboarding/index.tsx b/resources/js/pages/onboarding/index.tsx new file mode 100644 index 00000000..c236a08e --- /dev/null +++ b/resources/js/pages/onboarding/index.tsx @@ -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>(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 ; + + case 'encryption-explained': + return ; + + case 'encryption-setup': + return ; + + case 'account-types': + return ; + + case 'create-account': + return ( + { + goToStep('more-accounts'); + }} + /> + ); + + case 'category-types': + return ; + + case 'customize-categories': + return ( + + ); + + case 'smart-rules': + return ; + + case 'import-transactions': + return ( + + ); + + case 'import-balances': + return ( + + ); + + case 'more-accounts': + return ( + + ); + + case 'complete': + return ; + + default: + return null; + } + }; + + const getStepTitle = (step: OnboardingStep): string => { + const titles: Record = { + 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 ( + <> + + + {renderStep()} + + + ); +} diff --git a/resources/js/pages/subscription/paywall.tsx b/resources/js/pages/subscription/paywall.tsx index c47d75cf..4272bb32 100644 --- a/resources/js/pages/subscription/paywall.tsx +++ b/resources/js/pages/subscription/paywall.tsx @@ -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; +} + +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 ( -
- {isDefault && ( -
- Most Popular -
- )} - {isBestValue && ( -
- Best Value -
- )} -
-

{plan.name}

-
- {plan.original_price && ( - - ${plan.original_price} - - )} - ${plan.price} - - {getBillingLabel(plan.billing_period)} +
+
+ +
+ +
+

+ + {currentProof.highlight} + {' '} + + {currentProof.text} -

+

+
-
    - {plan.features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
- - - - + aria-label={`Go to slide ${index + 1}`} + /> + ))}
); } +function StatItem({ + icon: Icon, + value, + label, + delay = 0, +}: { + icon: React.ElementType; + value: number; + label: string; + delay?: number; +}) { + const animatedValue = useCountUp(value, { delay }); + + return ( +
+ + {animatedValue} + {label} +
+ ); +} + +function BalanceDisplay({ + balancesByCurrency, +}: { + balancesByCurrency: Record; +}) { + const entries = Object.entries(balancesByCurrency); + + if (entries.length === 0) { + return null; + } + + return ( +
+ +
+ {entries.map(([currency, amount]) => ( + + {formatCurrency(amount, currency)} + + ))} +
+ Balance +
+ ); +} + +function FinancialSnapshot({ stats }: { stats: PaywallStats }) { + const hasData = + stats.accountsCount > 0 || + stats.transactionsCount > 0 || + stats.categoriesCount > 0; + + if (!hasData) { + return null; + } + + return ( + + + {stats.accountsCount > 0 && ( + + )} + {stats.transactionsCount > 0 && ( + + )} + {stats.categoriesCount > 0 && ( + + )} + {Object.keys(stats.balancesByCurrency).length > 0 && ( + + )} + + + ); +} + +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 ( + + ); +} + +function PricingSection({ + planEntries, + defaultPlan, +}: { + planEntries: [string, Plan][]; + defaultPlan: string; +}) { + const [selectedPlan, setSelectedPlan] = useState(defaultPlan); + const selectedPlanData = planEntries.find( + ([key]) => key === selectedPlan, + )?.[1]; + + return ( +
+
+ {planEntries.map(([key, plan]) => ( + setSelectedPlan(key)} + /> + ))} +
+ + + + + + {selectedPlanData && ( +
    + {selectedPlanData.features.slice(0, 4).map((feature) => ( +
  • + + + {feature} + +
  • + ))} +
+ )} +
+ ); +} + +function PromoSection() { + return ( +

+ Your data is ready + • + + + + + Discord for $8 off + + + + You'll receive an exclusive promo code via DM! + + + +

+ ); +} + export default function Paywall() { - const { pricing } = usePage().props; + const { pricing, stats } = usePage().props; const planEntries = Object.entries(pricing.plans); if (planEntries.length === 0) { @@ -98,62 +371,20 @@ export default function Paywall() { return ( <> - + -
-
-
-

Upgrade to Pro

-

- Unlock all features and take control of your - finances -

-
+
+
+ -
= 3 && - 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3', - )} - > - {planEntries.map(([key, plan]) => ( - - ))} -
+ - {pricing.promo.enabled && ( -

- 🎉 Get a founder discount •{' '} - - - - - Join our Discord - - - - You'll receive an exclusive promo code - via DM! - - - -

- )} + + + {pricing.promo.enabled && }
diff --git a/routes/web.php b/routes/web.php index 710adf3d..fcc5272d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Browser/AmountInputTest.php b/tests/Browser/AmountInputTest.php index 8004465e..be056ab9 100644 --- a/tests/Browser/AmountInputTest.php +++ b/tests/Browser/AmountInputTest.php @@ -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]); diff --git a/tests/Browser/AuthenticationTest.php b/tests/Browser/AuthenticationTest.php index 3f3dcd96..a3515334 100644 --- a/tests/Browser/AuthenticationTest.php +++ b/tests/Browser/AuthenticationTest.php @@ -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, ]); diff --git a/tests/Browser/AutomationRuleBuilderTest.php b/tests/Browser/AutomationRuleBuilderTest.php index a3cfbee0..a3fe08b3 100644 --- a/tests/Browser/AutomationRuleBuilderTest.php +++ b/tests/Browser/AutomationRuleBuilderTest.php @@ -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); diff --git a/tests/Browser/BankAccountsTest.php b/tests/Browser/BankAccountsTest.php index caf5a6ff..5e1b5471 100644 --- a/tests/Browser/BankAccountsTest.php +++ b/tests/Browser/BankAccountsTest.php @@ -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, diff --git a/tests/Browser/CategoriesTest.php b/tests/Browser/CategoriesTest.php index 24394d38..1061b6c7 100644 --- a/tests/Browser/CategoriesTest.php +++ b/tests/Browser/CategoriesTest.php @@ -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', diff --git a/tests/Browser/ImportTransactionsTest.php b/tests/Browser/ImportTransactionsTest.php index 647ea0f8..d6ddc9d5 100644 --- a/tests/Browser/ImportTransactionsTest.php +++ b/tests/Browser/ImportTransactionsTest.php @@ -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, diff --git a/tests/Browser/OnboardingFlowTest.php b/tests/Browser/OnboardingFlowTest.php new file mode 100644 index 00000000..5d579cc5 --- /dev/null +++ b/tests/Browser/OnboardingFlowTest.php @@ -0,0 +1,460 @@ +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(); +}); diff --git a/tests/Browser/TransactionsTest.php b/tests/Browser/TransactionsTest.php index 134d4762..b56207d2 100644 --- a/tests/Browser/TransactionsTest.php +++ b/tests/Browser/TransactionsTest.php @@ -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]); diff --git a/tests/Feature/AccountControllerTest.php b/tests/Feature/AccountControllerTest.php index de95f537..4754a512 100644 --- a/tests/Feature/AccountControllerTest.php +++ b/tests/Feature/AccountControllerTest.php @@ -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); }); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index 64ea7277..778bbe0c 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -15,5 +15,5 @@ test('new users can register', function () { ]); $this->assertAuthenticated(); - $response->assertRedirect(route('dashboard', absolute: false)); + $response->assertRedirect(route('onboarding', absolute: false)); }); diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index 91d58221..22d5d728 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -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(); }); diff --git a/tests/Feature/EncryptionTest.php b/tests/Feature/EncryptionTest.php index 7cda8709..e17d77a3 100644 --- a/tests/Feature/EncryptionTest.php +++ b/tests/Feature/EncryptionTest.php @@ -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')); diff --git a/tests/Feature/Onboarding/OnboardingMiddlewareTest.php b/tests/Feature/Onboarding/OnboardingMiddlewareTest.php new file mode 100644 index 00000000..65ff64df --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingMiddlewareTest.php @@ -0,0 +1,80 @@ +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'); +}); diff --git a/tests/Feature/SitemapTest.php b/tests/Feature/SitemapTest.php index 22f5a32d..a23ecb8c 100644 --- a/tests/Feature/SitemapTest.php +++ b/tests/Feature/SitemapTest.php @@ -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'); }); diff --git a/tests/Feature/SubscriptionTest.php b/tests/Feature/SubscriptionTest.php index 125cc26a..eeae8b32 100644 --- a/tests/Feature/SubscriptionTest.php +++ b/tests/Feature/SubscriptionTest.php @@ -1,5 +1,9 @@ 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); diff --git a/tests/Feature/TransactionTest.php b/tests/Feature/TransactionTest.php index d85bfce8..165f7f4e 100644 --- a/tests/Feature/TransactionTest.php +++ b/tests/Feature/TransactionTest.php @@ -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 = [