diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php index ab975096..0374c197 100644 --- a/app/Http/Controllers/OnboardingController.php +++ b/app/Http/Controllers/OnboardingController.php @@ -2,7 +2,11 @@ namespace App\Http\Controllers; +use App\Enums\BankingConnectionStatus; use App\Models\Bank; +use App\Models\Category; +use App\Models\Transaction; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -24,12 +28,38 @@ class OnboardingController extends Controller ->with('bank:id,name,logo') ->get(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank_id', 'banking_connection_id']); + $categories = Category::query() + ->where('user_id', $user->id) + ->orderBy('name') + ->get(['id', 'name', 'icon', 'color', 'type']); + + $transactions = Transaction::query() + ->where('user_id', $user->id) + ->whereNull('category_id') + ->with(['account.bank:id,name,logo', 'labels:id,name,color']) + ->orderBy('transaction_date', 'desc') + ->orderBy('id', 'desc') + ->get(['id', 'account_id', 'category_id', 'description', 'description_iv', 'transaction_date', 'amount', 'currency_code', 'notes', 'notes_iv']); + return Inertia::render('onboarding/index', [ 'banks' => $banks, 'accounts' => $accounts, + 'categories' => $categories, + 'transactions' => $transactions, ]); } + public function syncStatus(Request $request): JsonResponse + { + $pending = $request->user() + ->bankingConnections() + ->where('status', BankingConnectionStatus::Active) + ->whereNull('last_synced_at') + ->exists(); + + return response()->json(['pending' => $pending]); + } + public function complete(Request $request): RedirectResponse { $request->user()->update([ diff --git a/lang/es.json b/lang/es.json index ffd92f55..8b1fa219 100644 --- a/lang/es.json +++ b/lang/es.json @@ -91,11 +91,13 @@ "Accounts need to be mapped before syncing can begin.": "Las cuentas deben mapearse antes de que pueda comenzar la sincronización.", "Accounts will become manual. All transactions and balances will be preserved.": "Las cuentas pasarán a ser manuales. Todas las transacciones y saldos se conservarán.", "Actions": "Acciones", + "Add another account": "Agregar otra cuenta", "Add Another Account": "Agregar Otra Cuenta", "Add Condition": "Agregar Condición", "Add Group": "Agregar Grupo", "Add Labels": "Agregar Etiquetas", "Add More Accounts": "Agregar Más Cuentas", + "Add more accounts or continue to set up your categories.": "Agrega más cuentas o continúa para configurar tus categorías.", "Add More Accounts?": "¿Agregar Más Cuentas?", "Add Transaction": "Agregar Transacción", "Add a new bank account to track your transactions.": "Agrega una nueva cuenta bancaria para rastrear tus transacciones.", @@ -126,6 +128,8 @@ "All your accounts at a glance": "Todas tus cuentas de un vistazo", "All your money in one place. No spreadsheets. Private.": "Todo tu dinero en un solo sitio. Sin Excels. Privado.", "All your transactions are already categorized.": "Todas tus transacciones ya están categorizadas.", + "All your transactions are already categorized. You are all set!": "Todas tus transacciones ya están categorizadas. ¡Estás listo!", + "Almost there, just double-checking the math...": "Casi listo, revisando los números una vez más...", "Allocated Amount": "Cantidad Asignado", "Allocated:": "Asignado:", "Already have an account?": "¿Ya tienes una cuenta?", @@ -164,13 +168,16 @@ "Automation rules settings": "Configuración de reglas de automatización", "Automation rules to categorize transactions automatically.": "Reglas de automatización para categorizar transacciones automáticamente.", "Auto-sync transactions directly from your bank": "Sincroniza automáticamente las transacciones desde tu banco", + "Automate over time": "Automatiza con el tiempo", "Available:": "Disponible:", "Back": "Atrás", + "Back to accounts": "Volver a las cuentas", "Blue": "Azul", "Back to Transactions": "Volver a Transacciones", "Balance": "Balance", "Balance (Optional)": "Balance (Opcional)", "Balance Date": "Fecha del Balance", + "Build better budgets": "Crea mejores presupuestos", "Balance History": "Historial de Balance", "Balance Tracking": "Seguimiento de Balance", "Balance evolution": "Evolución del balance", @@ -212,6 +219,7 @@ "Categories settings": "Configuración de categorías", "Categorize": "Categorizar", "Categorize Transactions": "Categorizar Transacciones", + "Categorize Your Transactions": "Categoriza tus transacciones", "Category": "Categoría", "Category (Optional)": "Categoría (Opcional)", "Category name": "Nombre de categoría", @@ -266,6 +274,7 @@ "Connected account": "Cuenta conectada", "Connected accounts are a Standard Plan feature. You'll choose a plan at the end of the onboarding.": "Las cuentas conectadas son una función del plan Standard. Elegirás un plan al final del proceso de incorporación.", "Connecting...": "Conectando...", + "Connecting the dots in your finances...": "Conectando los puntos de tus finanzas...", "Connections": "Conexiones", "Consider saving more if possible.": "Considera ahorrar más si es posible.", "Continue": "Continuar", @@ -273,6 +282,8 @@ "Continue Setup": "Continuar Configuración", "Continue to Import": "Continuar con Importación", "Country": "País", + "Counting every penny...": "Contando cada céntimo...", + "Crunching the numbers...": "Calculando los números...", "Create": "Crear", "Create \"": "Crear \"", "Create Account": "Crear Cuenta", @@ -296,7 +307,8 @@ "Create budgets that adapt to your spending habits and help you reach your goals.": "Crea presupuestos que se adapten a tus hábitos de gasto y te ayuden a alcanzar tus objetivos.", "Create new account": "Crear nueva cuenta", "Create rules like \"If description contains 'AMAZON', categorize as Shopping\"": "Crea reglas como \"Si la descripción contiene 'AMAZON', categorizar como Compras\"", - "Create rules like \"If description contains \\'AMAZON\\',\\n categorize as Shopping\"": "Crea reglas como \"Si la descripción contiene \\'AMAZON\\', categorizar como Compras\"", + "Create rules like \"If description contains \\'AMAZON\\', categorize as Shopping\"": "Crea reglas como \"Si la descripción contiene 'AMAZON', categorizar como Compras\"", + "Create realistic budgets based on your actual spending habits.": "Crea presupuestos realistas basados en tus hábitos de gasto reales.", "Create rules to automatically categorize your transactions based on patterns you define.": "Crea reglas para categorizar automáticamente tus transacciones basándote en patrones que definas.", "Create, edit, and delete rules anytime": "Crea, edita y elimina reglas en cualquier momento", "Creating subscription...": "Creando suscripción...", @@ -447,6 +459,7 @@ "Filter rules...": "Filtrar reglas...", "Filter rules....": "Filtrar reglas....", "Filters": "Filtros", + "Fetching your transactions...": "Obteniendo tus transacciones...", "Finally, a finance app that respects my privacy. Knowing my data isn't being shared gives me peace of mind.": "Por fin, una aplicación financiera que respeta mi privacidad. Saber que mis datos no se comparten me da tranquilidad.", "Finally, a finance app that respects my privacy. The encryption gives me peace of mind.": "Finalmente, una app de finanzas que respeta mi privacidad. La encriptación me da tranquilidad.", "Financial Advice Disclaimer:": "Descargo de Responsabilidad sobre Asesoramiento Financiero:", @@ -468,6 +481,7 @@ "Get Started for Free": "Empieza Gratis", "Get notified when you're close to your limit. Never overspend again.": "Recibe notificaciones cuando estés cerca de tu límite. Nunca vuelvas a gastar de más.", "Get help when you need it with priority email support.": "Obtén ayuda cuando la necesites con soporte prioritario por correo electrónico.", + "Get a clear picture of where your money goes every month.": "Obtén una imagen clara de a dónde va tu dinero cada mes.", "Get started quickly with your existing financial data.": "Comienza rápidamente con tus datos financieros existentes.", "Github": "Github", "Go to Dashboard": "Ir al Panel", @@ -515,6 +529,8 @@ "If you have any questions, concerns, or feedback\\n about these Terms of Service, please contact us:": "Si tienes alguna pregunta, inquietud o comentario sobre estos Términos de Servicio, contáctanos:", "If you have any questions, concerns, or requests regarding this Privacy Policy or our data practices, please contact us:": "Si tienes alguna pregunta, inquietud o solicitud con respecto a esta Política de Privacidad o nuestras prácticas de datos, por favor contáctanos:", "If you have any questions, concerns, or requests\\n regarding this Privacy Policy or our data\\n practices, please contact us:": "Si tienes alguna pregunta, inquietud o solicitud sobre esta Política de Privacidad o nuestras prácticas de datos, contáctanos:", + "If a transaction repeats with a certain frequency or is recurring, you can create an automatic rule for it by clicking this button.": "Si una transacción se repite con cierta frecuencia o es recurrente, puedes crear una regla automática para ella haciendo clic en este botón.", + "Identify categories where you can cut back and save more.": "Identifica las categorías donde puedes gastar menos y ahorrar más.", "If you'd like to come back, here's a special offer just for you:": "Si te gustaría volver, aquí tienes una oferta especial solo para ti:", "If you're having trouble clicking the \"Verify Email Address\" button, copy and paste the URL below into your web browser:": "Si tienes problemas para hacer clic en el botón \"Verificar Correo Electrónico\", copia y pega la URL de abajo en tu navegador web:", "If you're having trouble with the import or need help with your specific bank's format, just reply to this email. I personally handle support and I'm happy to help you figure it out.": "Si tienes problemas con la importación o necesitas ayuda con el formato de tu banco, simplemente responde a este correo. Yo personalmente manejo el soporte y estaré encantado de ayudarte.", @@ -535,6 +551,7 @@ "Import transactions in seconds": "Importa transacciones en segundos", "Import your transaction history to start tracking your finances.": "Importa tu historial de transacciones para empezar a rastrear tus finanzas.", "Importing Transactions": "Importando Transacciones", + "Importing your balances...": "Importando tus saldos...", "Income": "Ingresos", "Income (Salary, Freelance, Investments)": "Ingresos (Salario, Freelance, Inversiones)", "Income Sources": "Fuentes de Ingresos", @@ -576,6 +593,7 @@ "Let's Get Started": "Comencemos", "Let's Import Your Transactions": "Importemos Tus Transacciones", "Let's set up your first account to start tracking your finances.": "Configuremos tu primera cuenta para empezar a rastrear tus finanzas.", + "Let's start": "Vamos a empezar", "Let's start with your main checking account. You can add more accounts later.": "Empecemos con tu cuenta corriente principal. Puedes agregar más cuentas después.", "Lifetime": "De por vida", "Lifetime License": "Licencia de Por Vida", @@ -606,6 +624,7 @@ "MM-DD-YYYY (e.g., 12-31-2024)": "MM-DD-YYYY (ej., 12-31-2024)", "Maintain and promptly update your account information": "Mantén y actualiza prontamente tu información de cuenta", "Maintain and promptly update your account\\n information": "Mantén y actualiza puntualmente la información de tu cuenta", + "Making your money talk...": "Haciendo que tu dinero hable...", "Manage Plan": "Gestionar Plan", "Manage Subscription": "Gestionar Suscripción", "Manage your bank accounts": "Gestiona tus cuentas bancarias", @@ -725,6 +744,7 @@ "Paste your Binance API Secret": "Pega tu Secreto API de Binance", "Paste your Bitpanda API Key": "Pega tu Clave API de Bitpanda", "Paste your Indexa Capital API token": "Pega tu token API de Indexa Capital", + "Patience is a virtue (especially with money)...": "La paciencia es una virtud (especialmente con el dinero)...", "Pattern Matching": "Coincidencia de Patrones", "Payment Processors:": "Procesadores de Pago:", "Payments are processed by third-party payment processors and subject to their terms": "Los pagos son procesados por procesadores de pago de terceros y están sujetos a sus términos", @@ -789,6 +809,7 @@ "Remaining": "Restante", "Remaining balance carries over to next period": "El saldo restante se acumula al siguiente período", "Remaining balance returns to available money pool": "El saldo restante vuelve al fondo de dinero disponible", + "remaining": "restantes", "Remember me": "Recuérdame", "Remove all labels": "Eliminar todas las etiquetas", "Resend verification email": "Reenviar correo de verificación", @@ -814,7 +835,7 @@ "Rule title": "Título de regla", "Rules": "Reglas", "Rules apply automatically when you import new transactions": "Las reglas se aplican automáticamente cuando importas nuevas transacciones", - "Rules apply automatically when you import new\\n transactions": "Las reglas se aplican automáticamente cuando importas nuevas transacciones", + "Rules will categorize future transactions for you automatically.": "Las reglas categorizarán las transacciones futuras por ti automáticamente.", "STARBUCKS@TEKKA PLC": "STARBUCKS@TEKKA PLC", "Sarah M.": "Sarah M.", "Save": "Guardar", @@ -844,6 +865,7 @@ "Secure upload": "Carga segura", "See balances": "Ver balances", "See every account in one place. Track balances, monitor changes, and always know where you stand.": "Ve todas tus cuentas en un solo lugar. Sigue los saldos, monitoriza los cambios y sabe siempre cómo estás.", + "See where you spend": "Ve en qué gastas", "See where you stand in real-time. Visual progress bars show spending vs. budget.": "Ve tu situación en tiempo real. Las barras de progreso visuales muestran el gasto frente al presupuesto.", "Select Account": "Seleccionar Cuenta", "Select a category": "Seleccionar una categoría", @@ -909,6 +931,7 @@ "Smart categorization": "Categorización inteligente", "Something not working?": "¿Algo no funciona?", "Spending exceeds income this period.": "El gasto excede el ingreso en este período.", + "Spot savings opportunities": "Identifica oportunidades de ahorro", "Spent": "Gastado", "Spent:": "Gastado:", "Square images only (max": "Solo imágenes cuadradas (máx", @@ -944,6 +967,7 @@ "Take control of your finances with privacy-first money tracking. Let's set up your account in just a few minutes.": "Toma el control de tus finanzas con un seguimiento privado de tu dinero. Vamos a configurar tu cuenta en pocos minutos.", "Tap": "Toca", "Tap the": "Toca el botón", + "Teaching the robots to count...": "Enseñándole a los robots a contar...", "Technical Information:": "Información Técnica:", "Terms of Service": "Términos de Servicio", "Terms of Service - Whisper Money": "Términos de Servicio - Whisper Money", @@ -988,12 +1012,14 @@ "This will remove your encryption key from this browser session. You'll need to enter your password again to unlock encrypted content.": "Esto eliminará tu clave de encriptación de esta sesión del navegador. Necesitarás ingresar tu contraseña nuevamente para desbloquear el contenido encriptado.", "This will revoke access to your bank account data from :bank.": "Esto revocará el acceso a los datos de tu cuenta bancaria de :bank.", "This will take less than 5 minutes": "Esto tomará menos de 5 minutos", + "This will only take a moment.": "Esto solo tardará un momento.", "This will update the allocated amount for the current and future periods.": "Esto actualizará el valor asignado para el período actual y futuro.", "This will update the allocated amount for the\\n current and future periods.": "Esto actualizará el monto asignado para los períodos actuales y futuros.", "This would mean sharing your data with third parties": "Esto significaría compartir tus datos con terceros", "Title": "Título", "To": "Para", "To authenticate your access and protect your account security": "Para autenticar tu acceso y proteger la seguridad de tu cuenta", + "To continue, you need to categorize at least :count transactions.": "Para continuar, debes categorizar al menos :count transacciones.", "To authenticate your access and protect your\\n account security": "Para autenticar tu acceso y proteger la seguridad de tu cuenta", "To comply with legal obligations and enforce our terms": "Para cumplir con obligaciones legales y hacer cumplir nuestros términos", "To comply with legal obligations and enforce\\n our terms": "Para cumplir con las obligaciones legales y hacer cumplir nuestros términos", @@ -1364,7 +1390,6 @@ "— $9/month": "— $9/mes", "← Back to home": "← Volver al inicio", "🎉 Get a founder discount •": "🎉 Obtén un descuento de fundador •", - "You're on the waiting list — Whisper Money": "Estás en la lista de espera — Whisper Money", "You've joined the Whisper Money waiting list. Share your referral link to move up the queue.": "Te has unido a la lista de espera de Whisper Money. Comparte tu enlace de referido para avanzar en la fila.", "You're on the list!": "¡Estás en la lista!", @@ -1377,7 +1402,6 @@ "Your email address": "Tu correo electrónico", "Join Waitlist": "Unirse a la lista", "Join the waiting list. We'll let you know when you're in.": "Únete a la lista de espera. Te avisaremos cuando sea tu turno.", - "You're on the Whisper Money waiting list!": "¡Estás en la lista de espera de Whisper Money!", "Someone just joined Whisper Money with your link!": "¡Alguien acaba de unirse a Whisper Money con tu enlace!", "Hey there!": "¡Hola!", @@ -1408,7 +1432,6 @@ "Every person who joins through your link moves you another 10 spots closer to the front — so keep sharing!": "Cada persona que se una a través de tu enlace te acerca otros 10 puestos al frente — ¡sigue compartiendo!", "Share Your Link Again": "Comparte Tu Enlace de Nuevo", "Thanks for spreading the word. It means everything to us.": "Gracias por correr la voz. Significa todo para nosotros.", - "Someone just overtook you in the Whisper Money queue!": "¡Alguien te acaba de adelantar en la lista de espera de Whisper Money!", "Someone just overtook you in the queue!": "¡Alguien te acaba de adelantar en la fila!", "Hey! A quick heads-up.": "¡Hola! Un aviso rápido.", diff --git a/resources/js/components/onboarding/step-categorize-transactions.tsx b/resources/js/components/onboarding/step-categorize-transactions.tsx new file mode 100644 index 00000000..35d62899 --- /dev/null +++ b/resources/js/components/onboarding/step-categorize-transactions.tsx @@ -0,0 +1,373 @@ +import { AutomationRulesDialog } from '@/components/automation-rules/automation-rules-dialog'; +import { StepButton } from '@/components/onboarding/step-button'; +import { CategorizerCard } from '@/components/transactions/categorizer-card'; +import { CategorizerCommand } from '@/components/transactions/categorizer-command'; +import { Button } from '@/components/ui/button'; +import { Kbd } from '@/components/ui/kbd'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useCategorizeTransactions } from '@/hooks/use-categorize-transactions'; +import { type Account, type Bank } from '@/types/account'; +import { type Category } from '@/types/category'; +import { type Transaction } from '@/types/transaction'; +import { __ } from '@/utils/i18n'; +import { + CheckCircle2, + PieChart, + Settings2, + SkipForward, + Tag, + Target, + TrendingDown, + Zap, +} from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +interface StepCategorizeTransactionsProps { + categories: Category[]; + accounts: Account[]; + banks: Bank[]; + transactions: Transaction[]; + onComplete: () => void; +} + +export function StepCategorizeTransactions({ + categories, + accounts, + banks, + transactions, + onComplete, +}: StepCategorizeTransactionsProps) { + const [hasStarted, setHasStarted] = useState(false); + const [showRulesHint, setShowRulesHint] = useState(false); + const [hasSeenHint, setHasSeenHint] = useState(false); + + const { + isLoading, + isComplete, + uncategorizedTransactions, + currentTransaction, + remainingCount, + animationState, + lastSelectedCategory, + sortedCategories, + searchValue, + setSearchValue, + rulesDialogOpen, + setRulesDialogOpen, + categorizedCount, + handleCategorySelect, + handleSkip, + handleRulesDialogClose, + commandInputRef, + } = useCategorizeTransactions({ + categories, + accounts, + banks, + transactions, + }); + + const totalAvailable = uncategorizedTransactions.length; + const minimumRequired = Math.min(5, totalAvailable); + const canContinue = + isComplete || + categorizedCount >= minimumRequired || + totalAvailable === 0; + + const hasReachedMinimum = categorizedCount >= minimumRequired; + + // Show rules hint after first categorization, only once + useEffect(() => { + if (categorizedCount === 1 && !hasSeenHint) { + setShowRulesHint(true); + } + }, [categorizedCount, hasSeenHint]); + + const dismissRulesHint = useCallback(() => { + setShowRulesHint(false); + setHasSeenHint(true); + }, []); + + const handleRulesButtonClick = useCallback(() => { + dismissRulesHint(); + setRulesDialogOpen(true); + }, [dismissRulesHint, setRulesDialogOpen]); + + const handleRulesDialogCloseWithHint = useCallback( + async (open: boolean) => { + await handleRulesDialogClose(open); + }, + [handleRulesDialogClose], + ); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key === 'r') { + e.preventDefault(); + handleRulesButtonClick(); + } + if (e.ctrlKey && e.key === 'n') { + e.preventDefault(); + if ( + animationState === 'idle' && + currentTransaction && + !showRulesHint + ) { + handleSkip(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [ + animationState, + currentTransaction, + handleSkip, + handleRulesButtonClick, + showRulesHint, + ]); + + if (isLoading) { + return ( +
+ + +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ); + } + + if (totalAvailable === 0) { + return ( +
+
+ +
+
+

+ {__('No Uncategorized Transactions')} +

+

+ {__( + 'All your transactions are already categorized. You are all set!', + )} +

+
+ +
+ ); + } + + if (!hasStarted) { + return ( +
+
+ +
+
+

+ {__('Categorize Your Transactions')} +

+

+ {__( + 'To continue, you need to categorize at least :count transactions.', + { count: minimumRequired }, + )} +

+
+ +
+
+
+
+ +
+

+ {__('See where you spend')} +

+
+

+ {__( + 'Get a clear picture of where your money goes every month.', + )} +

+
+ +
+
+
+ +
+

+ {__('Build better budgets')} +

+
+

+ {__( + 'Create realistic budgets based on your actual spending habits.', + )} +

+
+ +
+
+
+ +
+

+ {__('Spot savings opportunities')} +

+
+

+ {__( + 'Identify categories where you can cut back and save more.', + )} +

+
+ +
+
+
+ +
+

+ {__('Automate over time')} +

+
+

+ {__( + 'Rules will categorize future transactions for you automatically.', + )} +

+
+
+ + setHasStarted(true)} + /> +
+ ); + } + + return ( +
+ {/* Header row */} +
+ {}}> + + + + e.preventDefault()} + > +
+

+ {__( + 'If a transaction repeats with a certain frequency or is recurring, you can create an automatic rule for it by clicking this button.', + )} +

+ +
+
+
+ +
+ + + + + + {hasReachedMinimum ? ( + <> + + {remainingCount} + {' '} + {__('remaining')} + + ) : ( + <> + + {categorizedCount} + + /{minimumRequired} + + )} + +
+
+ + {/* Categorizer card */} + + + {/* Category command palette */} + + + +
+ ); +} diff --git a/resources/js/components/onboarding/step-create-account.tsx b/resources/js/components/onboarding/step-create-account.tsx index 400f9bac..fd368e3b 100644 --- a/resources/js/components/onboarding/step-create-account.tsx +++ b/resources/js/components/onboarding/step-create-account.tsx @@ -4,6 +4,7 @@ import { AccountForm, AccountFormData, } from '@/components/accounts/account-form'; +import { BankLogo } from '@/components/bank-logo'; import { StepHeader } from '@/components/onboarding/step-header'; import { ConnectAccountInline } from '@/components/open-banking/connect-account-inline'; import { Button } from '@/components/ui/button'; @@ -14,10 +15,11 @@ import { __ } from '@/utils/i18n'; import { usePage } from '@inertiajs/react'; import { AlertCircle, - CheckCircle2, + Check, CreditCard, Link2, PencilLine, + Plus, Zap, } from 'lucide-react'; import { useCallback, useMemo, useRef, useState } from 'react'; @@ -25,6 +27,16 @@ import { StepButton } from './step-button'; type AccountMode = 'select' | 'manual' | 'connected'; +interface CreatedAccountDisplay { + id: string; + name: string; + type: string; + currencyCode: string; + bankName?: string; + bankLogo?: string | null; + connected?: boolean; +} + interface ExistingAccount { id: string; name: string; @@ -45,16 +57,18 @@ interface StepCreateAccountProps { banks: { id: string; name: string; logo: string | null }[]; isFirstAccount: boolean; existingAccounts?: ExistingAccount[]; + createdAccounts?: CreatedAccountDisplay[]; onAccountCreated: (account: CreatedAccount) => void; - onSkip?: () => void; + onContinue?: () => void; } export function StepCreateAccount({ banks, isFirstAccount, existingAccounts = [], + createdAccounts = [], onAccountCreated, - onSkip, + onContinue, }: StepCreateAccountProps) { const { pricing, subscriptionsEnabled, features } = usePage().props; @@ -63,6 +77,7 @@ export function StepCreateAccount({ const [selectedMode, setSelectedMode] = useState<'manual' | 'connected'>( 'manual', ); + const [isAddingAnother, setIsAddingAnother] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const formDataRef = useRef({ @@ -198,9 +213,14 @@ export function StepCreateAccount({ const accountData = await response.json(); + const matchedBank = banks.find( + (b) => String(b.id) === String(finalBankId), + ); const bankName = - formDataRef.current.customBank?.name ?? - banks.find((b) => String(b.id) === String(finalBankId))?.name; + formDataRef.current.customBank?.name ?? matchedBank?.name; + const bankLogo = formDataRef.current.customBank + ? null + : (matchedBank?.logo ?? null); onAccountCreated({ id: accountData.id || finalBankId, @@ -208,6 +228,7 @@ export function StepCreateAccount({ type: type, currencyCode: currencyCode, bankName, + bankLogo, }); setIsSubmitting(false); } catch (err) { @@ -222,9 +243,32 @@ export function StepCreateAccount({ } const hasExistingAccounts = existingAccounts.length > 0; + const hasCreatedAccounts = createdAccounts.length > 0; + + const createdAccountsByBank = useMemo(() => { + const groups = new Map(); + for (const account of createdAccounts) { + const key = account.bankName ?? 'Bank'; + const group = groups.get(key) ?? []; + group.push(account); + groups.set(key, group); + } + return Array.from(groups.entries()); + }, [createdAccounts]); + + const existingAccountsByBank = useMemo(() => { + const groups = new Map(); + for (const account of existingAccounts) { + const key = account.bank?.name ?? 'Bank'; + const group = groups.get(key) ?? []; + group.push(account); + groups.set(key, group); + } + return Array.from(groups.entries()); + }, [existingAccounts]); const { title, description } = useMemo(() => { - if (hasExistingAccounts) { + if (hasExistingAccounts && !isAddingAnother && !hasCreatedAccounts) { return { title: __('Your Accounts'), description: __( @@ -246,10 +290,86 @@ export function StepCreateAccount({ 'Add another account to track more of your finances.', ), }; - }, [hasExistingAccounts, isFirstAccount]); + }, [ + hasExistingAccounts, + isFirstAccount, + isAddingAnother, + hasCreatedAccounts, + ]); + + // Show created accounts list view (after creating accounts in this session) + if (hasCreatedAccounts && !isAddingAnother) { + return ( +
+ + +
+ {createdAccountsByBank.map(([bankName, accounts]) => ( +
+
+ + + {bankName} + +
+
+ {accounts.map((account) => ( +
+ + + {account.name} + + + {account.currencyCode} + +
+ ))} +
+
+ ))} +
+ +
+ + + +
+
+ ); + } // Show existing accounts view - if (hasExistingAccounts) { + if (hasExistingAccounts && !isAddingAnother) { return (
-
- {existingAccounts.map((account) => ( +
+ {existingAccountsByBank.map(([bankName, accounts]) => (
-
- +
+ + + {bankName} +
-
-

- {account.name || 'Account'} -

-

- - {account.bank?.name ?? `Bank`} - - - – - - - {account.type - .split('_') - .map( - (w) => - w - .charAt(0) - .toUpperCase() + - w.slice(1), - ) - .join(' ')} - - - – - - {account.currency_code} -

+
+ {accounts.map((account) => ( +
+ + + {account.name || 'Account'} + + + {account.type + .split('_') + .map( + (w) => + w + .charAt(0) + .toUpperCase() + + w.slice(1), + ) + .join(' ')}{' '} + · {account.currency_code} + +
+ ))}
))}
- - onAccountCreated({ - id: existingAccounts[0].id, - name: - existingAccounts[0].name || - existingAccounts[0].bank?.name || - 'Account', - type: existingAccounts[0].type, - currencyCode: existingAccounts[0].currency_code, - bankName: existingAccounts[0].bank?.name, - connected: - !!existingAccounts[0].banking_connection_id, - }) - } - /> +
+ + + +
); @@ -385,19 +509,6 @@ export function StepCreateAccount({ > {__('Back')} - - {!isFirstAccount && onSkip && ( - - )}
); @@ -494,23 +605,24 @@ export function StepCreateAccount({
)} - setMode(selectedMode)} - /> +
+ setMode(selectedMode)} + /> - {!isFirstAccount && onSkip && ( - - )} + {(hasCreatedAccounts || hasExistingAccounts) && ( + + )} +
); diff --git a/resources/js/components/onboarding/step-more-accounts.tsx b/resources/js/components/onboarding/step-more-accounts.tsx deleted file mode 100644 index c2a12a95..00000000 --- a/resources/js/components/onboarding/step-more-accounts.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { StepHeader } from '@/components/onboarding/step-header'; -import { Button } from '@/components/ui/button'; -import { CreatedAccount } from '@/hooks/use-onboarding-state'; -import { __ } from '@/utils/i18n'; -import { Check, CheckCircle2, Plus, Wallet } from 'lucide-react'; -import { StepButton } from './step-button'; - -interface ExistingAccount { - id: string; - name: string; - name_iv: string | null; - encrypted: boolean; - 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 createdIds = new Set(createdAccounts.map((a) => a.id)); - const filteredExistingAccounts = existingAccounts.filter( - (a) => !createdIds.has(a.id), - ); - - const description = __( - 'Would you like to add more accounts or continue to the dashboard?', - ); - - return ( -
- - -
-

- {__('Your Accounts')} -

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

{account.name}

-

- {account.bankName ?? 'Bank'} - - {account.currencyCode} -

-
- -
- ))} - {filteredExistingAccounts.map((account) => ( -
-
- -
-
-

- {account.name || 'Account'} -

-

- {account.bank?.name ?? `Bank`} - - {account.currency_code} -

-
- -
- ))} -
-
- -
-
-

- {__('Add More Accounts?')} -

-

- {__( - 'Track all your finances in one place \u2014 checking,\n 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 index 7ce4fc94..260fecbb 100644 --- a/resources/js/components/onboarding/step-smart-rules.tsx +++ b/resources/js/components/onboarding/step-smart-rules.tsx @@ -1,7 +1,7 @@ import { StepButton } from '@/components/onboarding/step-button'; import { StepHeader } from '@/components/onboarding/step-header'; import { __ } from '@/utils/i18n'; -import { Bot, Eye, EyeOff, Sparkles, Zap } from 'lucide-react'; +import { Sparkles, Zap } from 'lucide-react'; interface StepSmartRulesProps { onContinue: () => void; @@ -31,7 +31,7 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {

{__( - 'Create rules like "If description contains \'AMAZON\',\n categorize as Shopping"', + 'Create rules like "If description contains \'AMAZON\', categorize as Shopping"', )}

@@ -47,75 +47,12 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {

{__( - 'Rules apply automatically when you import new\n transactions', + 'Rules apply automatically when you import new transactions', )}

-
-
-
-

- {__('Why No AI Auto-Categorization?')} -

-

- {__('Privacy comes first')} -

-
-
- -
-
-
- -
-
-

- {__( - 'AI requires sending your data to external\n servers', - )} -

-

- {__( - 'This would mean sharing your data with third parties', - )} -

-
-
- -
-
- -
-
-

- {__('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-syncing.tsx b/resources/js/components/onboarding/step-syncing.tsx new file mode 100644 index 00000000..f7f157cf --- /dev/null +++ b/resources/js/components/onboarding/step-syncing.tsx @@ -0,0 +1,116 @@ +import { syncStatus } from '@/routes/onboarding'; +import { __ } from '@/utils/i18n'; +import { router } from '@inertiajs/react'; +import axios from 'axios'; +import { Loader2 } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +const MESSAGES = [ + 'Importing your balances...', + 'Fetching your transactions...', + 'Teaching the robots to count...', + 'Making your money talk...', + 'Crunching the numbers...', + 'Patience is a virtue (especially with money)...', + 'Counting every penny...', + 'Connecting the dots in your finances...', + 'Almost there, just double-checking the math...', +] as const; + +interface StepSyncingProps { + onComplete: () => void; +} + +export function StepSyncing({ onComplete }: StepSyncingProps) { + const [messageIndex, setMessageIndex] = useState(0); + const [isPending, setIsPending] = useState(null); + const onCompleteRef = useRef(onComplete); + onCompleteRef.current = onComplete; + + const advance = useCallback((hadPendingSync: boolean) => { + if (hadPendingSync) { + // Reload transactions so the categorize step sees newly synced data. + router.reload({ + only: ['transactions'], + onFinish: () => onCompleteRef.current(), + }); + } else { + // Sync was never pending (e.g. CSV-import user): skip the reload so + // the transactions prop stays as it was at page load. + onCompleteRef.current(); + } + }, []); + + // Check sync status immediately on mount, then poll every 3 seconds + useEffect(() => { + let cancelled = false; + let pollTimer: ReturnType; + + const check = async (hadPendingSync: boolean = false) => { + try { + const { data } = await axios.get<{ pending: boolean }>( + syncStatus().url, + ); + + if (cancelled) { + return; + } + + if (!data.pending) { + setIsPending(false); + advance(hadPendingSync); + } else { + setIsPending(true); + pollTimer = setTimeout(() => check(true), 3000); + } + } catch { + if (!cancelled) { + // On error, advance anyway to not block the user + advance(hadPendingSync); + } + } + }; + + check(); + + return () => { + cancelled = true; + clearTimeout(pollTimer); + }; + }, [advance]); + + // Rotate through messages every 2 seconds while pending + useEffect(() => { + if (!isPending) { + return; + } + + const interval = setInterval(() => { + setMessageIndex((i) => (i + 1) % MESSAGES.length); + }, 2000); + + return () => clearInterval(interval); + }, [isPending]); + + // Don't render anything until we know sync is pending + if (!isPending) { + return null; + } + + return ( +
+
+ +
+ +
+

+ {__(MESSAGES[messageIndex])} +

+

+ {__('This will only take a moment.')} +

+
+
+ ); +} diff --git a/resources/js/components/transactions/categorizer-card.tsx b/resources/js/components/transactions/categorizer-card.tsx new file mode 100644 index 00000000..57d058e2 --- /dev/null +++ b/resources/js/components/transactions/categorizer-card.tsx @@ -0,0 +1,153 @@ +import { AccountName } from '@/components/accounts/account-name'; +import { BankLogo } from '@/components/bank-logo'; +import { AmountDisplay } from '@/components/ui/amount-display'; +import { type AnimationState } from '@/hooks/use-categorize-transactions'; +import { useLocale } from '@/hooks/use-locale'; +import { cn } from '@/lib/utils'; +import { type Category, getCategoryColorClasses } from '@/types/category'; +import { type DecryptedTransaction } from '@/types/transaction'; +import { formatDateLong } from '@/utils/date'; +import { CheckCircle2 } from 'lucide-react'; + +interface CategorizerCardProps { + transaction: DecryptedTransaction | undefined; + animationState: AnimationState; + lastSelectedCategory: Category | null; +} + +export function CategorizerCard({ + transaction, + animationState, + lastSelectedCategory, +}: CategorizerCardProps) { + const locale = useLocale(); + + return ( +
+ {animationState === 'success' && lastSelectedCategory && ( +
+
+
+ +
+ + {lastSelectedCategory.name} + +
+
+ )} + + {animationState !== 'success' && transaction && ( +
+
+
+
+
+

+ {formatDateLong( + transaction.transaction_date, + locale, + )} +

+ +

+ {transaction.decryptedDescription || + 'Encrypted'} +

+ + {transaction.account && ( +
+ + +
+ )} +
+
+ + {transaction.decryptedNotes && ( +

+ {transaction.decryptedNotes} +

+ )} +
+ +
+ = 0} + /> +
+
+
+ )} + + +
+ ); +} diff --git a/resources/js/components/transactions/categorizer-command.tsx b/resources/js/components/transactions/categorizer-command.tsx new file mode 100644 index 00000000..2654fedf --- /dev/null +++ b/resources/js/components/transactions/categorizer-command.tsx @@ -0,0 +1,135 @@ +import { CategoryIcon } from '@/components/shared/category-combobox'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Kbd } from '@/components/ui/kbd'; +import { type AnimationState } from '@/hooks/use-categorize-transactions'; +import { cn } from '@/lib/utils'; +import { type Category, getCategoryColorClasses } from '@/types/category'; +import { type DecryptedTransaction } from '@/types/transaction'; +import { __ } from '@/utils/i18n'; +import { ArrowDown, ArrowUp } from 'lucide-react'; +import { type RefObject } from 'react'; + +interface CategorizerCommandProps { + sortedCategories: Category[]; + animationState: AnimationState; + currentTransaction: DecryptedTransaction | undefined; + searchValue: string; + onSearchChange: (value: string) => void; + onCategorySelect: (category: Category) => void; + commandInputRef: RefObject; + disabled?: boolean; +} + +export function CategorizerCommand({ + sortedCategories, + animationState, + currentTransaction, + searchValue, + onSearchChange, + onCategorySelect, + commandInputRef, + disabled = false, +}: CategorizerCommandProps) { + if (animationState === 'success' || !currentTransaction) { + return null; + } + + return ( +
+
+

{__('Assign a new category')}

+

+ {__('Search, move')}{' '} + + + + + + + {__(', and press')} + +

+
+ + + + + {__('No categories found.')} + + {sortedCategories.map((category) => { + const colorClasses = getCategoryColorClasses( + category.color, + ); + return ( + onCategorySelect(category)} + disabled={ + animationState !== 'idle' || disabled + } + className="group cursor-pointer gap-3 p-2" + > +
+ +
+ + {category.name} + +
+ ); + })} +
+
+
+ + +
+ ); +} diff --git a/resources/js/hooks/use-categorize-transactions.ts b/resources/js/hooks/use-categorize-transactions.ts new file mode 100644 index 00000000..e24ea3c6 --- /dev/null +++ b/resources/js/hooks/use-categorize-transactions.ts @@ -0,0 +1,416 @@ +import { useEncryptionKey } from '@/contexts/encryption-key-context'; +import { decrypt, importKey } from '@/lib/crypto'; +import { getStoredKey } from '@/lib/key-storage'; +import { evaluateRules } from '@/lib/rule-engine'; +import { transactionSyncService } from '@/services/transaction-sync'; +import { type Account, type Bank } from '@/types/account'; +import { type AutomationRule } from '@/types/automation-rule'; +import { type Category } from '@/types/category'; +import { + type DecryptedTransaction, + type Transaction, +} from '@/types/transaction'; +import { usePage } from '@inertiajs/react'; +import { parseISO } from 'date-fns'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +export type AnimationState = 'idle' | 'exiting' | 'entering' | 'success'; + +const CATEGORY_USAGE_KEY = 'category-usage-order'; + +export function getCategoryUsageOrder(): string[] { + try { + const stored = localStorage.getItem(CATEGORY_USAGE_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch { + // Ignore errors + } + return []; +} + +export function updateCategoryUsageOrder(categoryId: string): void { + try { + const current = getCategoryUsageOrder(); + const filtered = current.filter((id) => id !== categoryId); + const updated = [categoryId, ...filtered]; + localStorage.setItem(CATEGORY_USAGE_KEY, JSON.stringify(updated)); + } catch { + // Ignore errors + } +} + +function sortCategoriesByUsage( + categories: Category[], + usageOrder: string[], +): Category[] { + const orderMap = new Map(usageOrder.map((id, index) => [id, index])); + + return [...categories].sort((a, b) => { + const aIndex = orderMap.get(a.id) ?? Infinity; + const bIndex = orderMap.get(b.id) ?? Infinity; + + if (aIndex === bIndex) { + return a.name.localeCompare(b.name); + } + + return aIndex - bIndex; + }); +} + +interface UseCategorizeTransactionsOptions { + categories: Category[]; + accounts: Account[]; + banks: Bank[]; + transactions: Transaction[]; +} + +export function useCategorizeTransactions({ + categories, + accounts, + banks, + transactions: initialTransactions, +}: UseCategorizeTransactionsOptions) { + const { isKeySet } = useEncryptionKey(); + const { automationRules: sharedAutomationRules } = usePage<{ + automationRules: AutomationRule[]; + }>().props; + + const [uncategorizedTransactions, setUncategorizedTransactions] = useState< + DecryptedTransaction[] + >([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [animationState, setAnimationState] = + useState('idle'); + const [categoryUsageOrder, setCategoryUsageOrder] = useState([]); + const [lastSelectedCategory, setLastSelectedCategory] = + useState(null); + const [searchValue, setSearchValue] = useState(''); + const [rulesDialogOpen, setRulesDialogOpen] = useState(false); + const [encryptionKey, setEncryptionKey] = useState(null); + const [categorizedCount, setCategorizedCount] = useState(0); + const commandInputRef = useRef(null); + + const automationRules = useMemo( + () => + sharedAutomationRules.map((rule) => ({ + ...rule, + rules_json: + typeof rule.rules_json === 'string' + ? JSON.parse(rule.rules_json) + : rule.rules_json, + })) as AutomationRule[], + [sharedAutomationRules], + ); + + useEffect(() => { + setCategoryUsageOrder(getCategoryUsageOrder()); + }, []); + + useEffect(() => { + if (!isLoading && animationState === 'idle') { + commandInputRef.current?.focus(); + } + }, [isLoading, animationState, currentIndex]); + + useEffect(() => { + async function decryptTransactions() { + setIsLoading(true); + try { + const accountsMap = new Map( + accounts.map((account) => [account.id, account]), + ); + const banksMap = new Map(banks.map((bank) => [bank.id, bank])); + + const keyString = getStoredKey(); + let key: CryptoKey | null = null; + + if (keyString && isKeySet) { + try { + key = await importKey(keyString); + setEncryptionKey(key); + } catch (error) { + console.error( + 'Failed to import encryption key:', + error, + ); + } + } + + const decrypted = await Promise.all( + initialTransactions.map(async (transaction) => { + try { + let decryptedDescription = ''; + let decryptedNotes: string | null = null; + + if (!transaction.description_iv) { + decryptedDescription = transaction.description; + decryptedNotes = transaction.notes || null; + } else if (key) { + try { + decryptedDescription = await decrypt( + transaction.description, + key, + transaction.description_iv, + ); + + if ( + transaction.notes && + transaction.notes_iv + ) { + decryptedNotes = await decrypt( + transaction.notes, + key, + transaction.notes_iv, + ); + } + } catch (error) { + console.error( + 'Failed to decrypt transaction:', + transaction.id, + error, + ); + } + } + + const account = accountsMap.get( + transaction.account_id, + ); + const bank = account?.bank?.id + ? banksMap.get(account.bank.id) + : undefined; + + return { + ...transaction, + decryptedDescription, + decryptedNotes, + account, + category: null, + bank, + } as DecryptedTransaction; + } catch (error) { + console.error( + 'Failed to process transaction:', + transaction.id, + error, + ); + return null; + } + }), + ); + + const validTransactions = decrypted.filter( + (transaction): transaction is DecryptedTransaction => + transaction !== null, + ); + + validTransactions.sort((a, b) => { + const dateA = parseISO(a.transaction_date).getTime(); + const dateB = parseISO(b.transaction_date).getTime(); + return dateB - dateA; + }); + + setUncategorizedTransactions(validTransactions); + } catch (error) { + console.error( + 'Failed to load uncategorized transactions:', + error, + ); + } finally { + setIsLoading(false); + } + } + + decryptTransactions(); + }, [initialTransactions, accounts, banks, isKeySet]); + + const currentTransaction = uncategorizedTransactions[currentIndex]; + const remainingCount = uncategorizedTransactions.length - currentIndex; + const isComplete = currentIndex >= uncategorizedTransactions.length; + + const sortedCategories = useMemo(() => { + return sortCategoriesByUsage(categories, categoryUsageOrder); + }, [categories, categoryUsageOrder]); + + const applyAutomationRulesToQueue = useCallback(async () => { + if ( + !encryptionKey || + !automationRules.length || + uncategorizedTransactions.length === 0 + ) { + return; + } + + const remainingTransactions = + uncategorizedTransactions.slice(currentIndex); + let appliedCount = 0; + const newUncategorizedList: DecryptedTransaction[] = [ + ...uncategorizedTransactions.slice(0, currentIndex), + ]; + + for (const transaction of remainingTransactions) { + const result = await evaluateRules( + transaction, + automationRules, + categories, + accounts, + banks, + encryptionKey, + ); + + if (result?.categoryId) { + try { + await transactionSyncService.update(transaction.id, { + category_id: result.categoryId, + }); + appliedCount++; + + const matchedCategory = categories.find( + (c) => c.id === result.categoryId, + ); + if (matchedCategory) { + updateCategoryUsageOrder(matchedCategory.id); + } + } catch (error) { + console.error( + 'Failed to apply automation rule to transaction:', + error, + ); + newUncategorizedList.push(transaction); + } + } else { + newUncategorizedList.push(transaction); + } + } + + if (appliedCount > 0) { + setCategoryUsageOrder(getCategoryUsageOrder()); + setUncategorizedTransactions(newUncategorizedList); + setCurrentIndex( + Math.min(currentIndex, newUncategorizedList.length), + ); + } + + return appliedCount; + }, [ + encryptionKey, + automationRules, + uncategorizedTransactions, + currentIndex, + categories, + accounts, + banks, + ]); + + const handleRulesDialogClose = useCallback( + async (open: boolean) => { + setRulesDialogOpen(open); + + if (!open) { + await applyAutomationRulesToQueue(); + commandInputRef.current?.focus(); + } + }, + [applyAutomationRulesToQueue], + ); + + const handleCategorySelect = useCallback( + async (category: Category) => { + if (!currentTransaction || animationState !== 'idle') { + return; + } + + setLastSelectedCategory(category); + setAnimationState('exiting'); + + try { + await transactionSyncService.update(currentTransaction.id, { + category_id: category.id, + }); + + updateCategoryUsageOrder(category.id); + setCategoryUsageOrder(getCategoryUsageOrder()); + setCategorizedCount((prev) => prev + 1); + } catch (error) { + console.error('Failed to update transaction:', error); + setAnimationState('idle'); + return; + } + + setTimeout(() => { + setAnimationState('success'); + + setTimeout(() => { + setCurrentIndex((prev) => prev + 1); + setAnimationState('entering'); + setSearchValue(''); + + setTimeout(() => { + setAnimationState('idle'); + setLastSelectedCategory(null); + commandInputRef.current?.focus(); + }, 300); + }, 400); + }, 300); + }, + [currentTransaction, animationState], + ); + + const handleSkip = useCallback(() => { + if (animationState !== 'idle') { + return; + } + + setAnimationState('exiting'); + + setTimeout(() => { + setCurrentIndex((prev) => prev + 1); + setAnimationState('entering'); + + setTimeout(() => { + setAnimationState('idle'); + }, 300); + }, 300); + }, [animationState]); + + const handlePrevious = useCallback(() => { + if (animationState !== 'idle' || currentIndex === 0) { + return; + } + + setAnimationState('exiting'); + + setTimeout(() => { + setCurrentIndex((prev) => prev - 1); + setAnimationState('entering'); + + setTimeout(() => { + setAnimationState('idle'); + }, 300); + }, 300); + }, [animationState, currentIndex]); + + return { + isLoading, + isComplete, + uncategorizedTransactions, + currentTransaction, + currentIndex, + remainingCount, + animationState, + lastSelectedCategory, + sortedCategories, + searchValue, + setSearchValue, + rulesDialogOpen, + setRulesDialogOpen, + categorizedCount, + handleCategorySelect, + handleSkip, + handlePrevious, + handleRulesDialogClose, + commandInputRef, + }; +} diff --git a/resources/js/hooks/use-onboarding-state.ts b/resources/js/hooks/use-onboarding-state.ts index 40058323..69ca6472 100644 --- a/resources/js/hooks/use-onboarding-state.ts +++ b/resources/js/hooks/use-onboarding-state.ts @@ -7,9 +7,10 @@ export type OnboardingStep = | 'category-types' | 'customize-categories' | 'smart-rules' + | 'syncing' | 'import-transactions' | 'import-balances' - | 'more-accounts' + | 'categorize-transactions' | 'complete'; // Primary steps shown in the progress indicator @@ -20,7 +21,8 @@ const PRIMARY_STEPS: OnboardingStep[] = [ 'create-account', 'category-types', 'smart-rules', - 'more-accounts', + 'syncing', + 'categorize-transactions', 'complete', ]; @@ -41,6 +43,7 @@ export interface CreatedAccount { type: string; currencyCode: string; bankName?: string; + bankLogo?: string | null; connected?: boolean; } diff --git a/resources/js/layouts/onboarding-layout.tsx b/resources/js/layouts/onboarding-layout.tsx index 47d8ebd4..68754fb6 100644 --- a/resources/js/layouts/onboarding-layout.tsx +++ b/resources/js/layouts/onboarding-layout.tsx @@ -6,6 +6,7 @@ interface OnboardingLayoutProps { currentStep: number; totalSteps: number; stepKey: string; + align?: 'start' | 'center'; } export default function OnboardingLayout({ @@ -13,6 +14,7 @@ export default function OnboardingLayout({ currentStep, totalSteps, stepKey, + align = 'start', }: PropsWithChildren) { const [isVisible, setIsVisible] = useState(false); @@ -51,7 +53,12 @@ export default function OnboardingLayout({
-
+
{ + // Always return to create-account so the user can add more accounts or continue goToStep('create-account'); }; - const handleFinishOnboarding = () => { - goToStep('complete'); - }; - const renderStep = () => { const lastAccount = createdAccounts[createdAccounts.length - 1]; @@ -154,13 +149,13 @@ export default function Onboarding({ banks, accounts }: OnboardingProps) { case 'create-account': return ( { - goToStep('more-accounts'); - }} + onContinue={goNext} /> ); @@ -178,6 +173,9 @@ export default function Onboarding({ banks, accounts }: OnboardingProps) { case 'smart-rules': return ; + case 'syncing': + return ; + case 'import-transactions': return ( ); - case 'more-accounts': + case 'categorize-transactions': return ( - ); @@ -220,9 +219,10 @@ export default function Onboarding({ banks, accounts }: OnboardingProps) { 'category-types': __('Categories'), 'customize-categories': __('Customize Categories'), 'smart-rules': __('Smart Rules'), + syncing: __('Syncing'), 'import-transactions': __('Import Transactions'), 'import-balances': __('Set Balance'), - 'more-accounts': __('Add More Accounts'), + 'categorize-transactions': __('Categorize Transactions'), complete: __('All Set!'), }; return titles[step]; diff --git a/resources/js/pages/transactions/categorize.tsx b/resources/js/pages/transactions/categorize.tsx index 60b025a4..63cda9dc 100644 --- a/resources/js/pages/transactions/categorize.tsx +++ b/resources/js/pages/transactions/categorize.tsx @@ -1,52 +1,27 @@ import { categorize as categorizeRoute } from '@/actions/App/Http/Controllers/TransactionController'; -import { AccountName } from '@/components/accounts/account-name'; import { AutomationRulesDialog } from '@/components/automation-rules/automation-rules-dialog'; -import { CategoryIcon } from '@/components/shared/category-combobox'; -import { AmountDisplay } from '@/components/ui/amount-display'; +import { CategorizerCard } from '@/components/transactions/categorizer-card'; +import { CategorizerCommand } from '@/components/transactions/categorizer-command'; import { Button } from '@/components/ui/button'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command'; import { Kbd } from '@/components/ui/kbd'; import { Skeleton } from '@/components/ui/skeleton'; -import { useEncryptionKey } from '@/contexts/encryption-key-context'; -import { useLocale } from '@/hooks/use-locale'; -import { decrypt, importKey } from '@/lib/crypto'; -import { getStoredKey } from '@/lib/key-storage'; -import { evaluateRules } from '@/lib/rule-engine'; -import { cn } from '@/lib/utils'; -import { transactionSyncService } from '@/services/transaction-sync'; +import { useCategorizeTransactions } from '@/hooks/use-categorize-transactions'; import { type Account, type Bank } from '@/types/account'; -import { type AutomationRule } from '@/types/automation-rule'; -import { type Category, getCategoryColorClasses } from '@/types/category'; -import { - type DecryptedTransaction, - type Transaction, -} from '@/types/transaction'; -import { formatDateLong } from '@/utils/date'; +import { type Category } from '@/types/category'; +import { type Transaction } from '@/types/transaction'; import { __ } from '@/utils/i18n'; -import { Head, Link, router, usePage } from '@inertiajs/react'; -import { parseISO } from 'date-fns'; +import { Head, Link, router } from '@inertiajs/react'; import { - ArrowDown, ArrowLeft, - ArrowUp, CheckCircle2, PartyPopper, Settings2, SkipBack, SkipForward, } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect } from 'react'; import { useWebHaptics } from 'web-haptics/react'; -const CATEGORY_USAGE_KEY = 'category-usage-order'; - interface Props { categories: Category[]; accounts: Account[]; @@ -54,400 +29,64 @@ interface Props { transactions: Transaction[]; } -type AnimationState = 'idle' | 'exiting' | 'entering' | 'success'; - -function getCategoryUsageOrder(): string[] { - try { - const stored = localStorage.getItem(CATEGORY_USAGE_KEY); - if (stored) { - return JSON.parse(stored); - } - } catch { - // Ignore errors - } - return []; -} - -function updateCategoryUsageOrder(categoryId: string): void { - try { - const current = getCategoryUsageOrder(); - const filtered = current.filter((id) => id !== categoryId); - const updated = [categoryId, ...filtered]; - localStorage.setItem(CATEGORY_USAGE_KEY, JSON.stringify(updated)); - } catch { - // Ignore errors - } -} - -function sortCategoriesByUsage( - categories: Category[], - usageOrder: string[], -): Category[] { - const orderMap = new Map(usageOrder.map((id, index) => [id, index])); - - return [...categories].sort((a, b) => { - const aIndex = orderMap.get(a.id) ?? Infinity; - const bIndex = orderMap.get(b.id) ?? Infinity; - - if (aIndex === bIndex) { - return a.name.localeCompare(b.name); - } - - return aIndex - bIndex; - }); -} - export default function CategorizeTransactions({ categories, accounts, banks, - transactions: initialTransactions, + transactions, }: Props) { - const { isKeySet } = useEncryptionKey(); - const { automationRules: sharedAutomationRules } = usePage<{ - automationRules: AutomationRule[]; - }>().props; - const locale = useLocale(); const { trigger } = useWebHaptics(); - const [uncategorizedTransactions, setUncategorizedTransactions] = useState< - DecryptedTransaction[] - >([]); - const [currentIndex, setCurrentIndex] = useState(0); - const [isLoading, setIsLoading] = useState(true); - const [animationState, setAnimationState] = - useState('idle'); - const [categoryUsageOrder, setCategoryUsageOrder] = useState([]); - const [lastSelectedCategory, setLastSelectedCategory] = - useState(null); - const [searchValue, setSearchValue] = useState(''); - const [rulesDialogOpen, setRulesDialogOpen] = useState(false); - const [encryptionKey, setEncryptionKey] = useState(null); - const commandInputRef = useRef(null); - - const automationRules = useMemo( - () => - sharedAutomationRules.map((rule) => ({ - ...rule, - rules_json: - typeof rule.rules_json === 'string' - ? JSON.parse(rule.rules_json) - : rule.rules_json, - })) as AutomationRule[], - [sharedAutomationRules], - ); - - useEffect(() => { - setCategoryUsageOrder(getCategoryUsageOrder()); - }, []); - - useEffect(() => { - if (!isLoading && animationState === 'idle') { - commandInputRef.current?.focus(); - } - }, [isLoading, animationState, currentIndex]); - - // Decrypt transactions received from the backend and build the initial list - useEffect(() => { - async function decryptTransactions() { - setIsLoading(true); - try { - const accountsMap = new Map( - accounts.map((account) => [account.id, account]), - ); - const banksMap = new Map(banks.map((bank) => [bank.id, bank])); - - const keyString = getStoredKey(); - let key: CryptoKey | null = null; - - if (keyString && isKeySet) { - try { - key = await importKey(keyString); - setEncryptionKey(key); - } catch (error) { - console.error( - 'Failed to import encryption key:', - error, - ); - } - } - - const decrypted = await Promise.all( - initialTransactions.map(async (transaction) => { - try { - let decryptedDescription = ''; - let decryptedNotes: string | null = null; - - if (!transaction.description_iv) { - decryptedDescription = transaction.description; - decryptedNotes = transaction.notes || null; - } else if (key) { - try { - decryptedDescription = await decrypt( - transaction.description, - key, - transaction.description_iv, - ); - - if ( - transaction.notes && - transaction.notes_iv - ) { - decryptedNotes = await decrypt( - transaction.notes, - key, - transaction.notes_iv, - ); - } - } catch (error) { - console.error( - 'Failed to decrypt transaction:', - transaction.id, - error, - ); - } - } - - const account = accountsMap.get( - transaction.account_id, - ); - const bank = account?.bank?.id - ? banksMap.get(account.bank.id) - : undefined; - - return { - ...transaction, - decryptedDescription, - decryptedNotes, - account, - category: null, - bank, - } as DecryptedTransaction; - } catch (error) { - console.error( - 'Failed to process transaction:', - transaction.id, - error, - ); - return null; - } - }), - ); - - const validTransactions = decrypted.filter( - (transaction): transaction is DecryptedTransaction => - transaction !== null, - ); - - validTransactions.sort((a, b) => { - const dateA = parseISO(a.transaction_date).getTime(); - const dateB = parseISO(b.transaction_date).getTime(); - return dateB - dateA; - }); - - setUncategorizedTransactions(validTransactions); - } catch (error) { - console.error( - 'Failed to load uncategorized transactions:', - error, - ); - } finally { - setIsLoading(false); - } - } - - decryptTransactions(); - }, [initialTransactions, accounts, banks, isKeySet]); - - const currentTransaction = uncategorizedTransactions[currentIndex]; - const remainingCount = uncategorizedTransactions.length - currentIndex; - const isComplete = currentIndex >= uncategorizedTransactions.length; - - const sortedCategories = useMemo(() => { - return sortCategoriesByUsage(categories, categoryUsageOrder); - }, [categories, categoryUsageOrder]); - - const applyAutomationRulesToQueue = useCallback(async () => { - if ( - !encryptionKey || - !automationRules.length || - uncategorizedTransactions.length === 0 - ) { - return; - } - - const remainingTransactions = - uncategorizedTransactions.slice(currentIndex); - let categorizedCount = 0; - const newUncategorizedList: DecryptedTransaction[] = [ - ...uncategorizedTransactions.slice(0, currentIndex), - ]; - - for (const transaction of remainingTransactions) { - const result = await evaluateRules( - transaction, - automationRules, - categories, - accounts, - banks, - encryptionKey, - ); - - if (result?.categoryId) { - try { - await transactionSyncService.update(transaction.id, { - category_id: result.categoryId, - }); - categorizedCount++; - - const matchedCategory = categories.find( - (c) => c.id === result.categoryId, - ); - if (matchedCategory) { - updateCategoryUsageOrder(matchedCategory.id); - } - } catch (error) { - console.error( - 'Failed to apply automation rule to transaction:', - error, - ); - newUncategorizedList.push(transaction); - } - } else { - newUncategorizedList.push(transaction); - } - } - - if (categorizedCount > 0) { - setCategoryUsageOrder(getCategoryUsageOrder()); - setUncategorizedTransactions(newUncategorizedList); - setCurrentIndex( - Math.min(currentIndex, newUncategorizedList.length), - ); - } - - return categorizedCount; - }, [ - encryptionKey, - automationRules, + const { + isLoading, + isComplete, uncategorizedTransactions, + currentTransaction, currentIndex, + remainingCount, + animationState, + lastSelectedCategory, + sortedCategories, + searchValue, + setSearchValue, + rulesDialogOpen, + setRulesDialogOpen, + handleCategorySelect, + handleSkip, + handlePrevious, + handleRulesDialogClose, + commandInputRef, + } = useCategorizeTransactions({ categories, accounts, banks, - ]); + transactions, + }); - const handleRulesDialogClose = useCallback( - async (open: boolean) => { - setRulesDialogOpen(open); + const backHref = categorizeRoute.url()?.replace('/categorize', '') ?? ''; - if (!open) { - await applyAutomationRulesToQueue(); - commandInputRef.current?.focus(); - } - }, - [applyAutomationRulesToQueue], - ); - - const handleCategorySelect = useCallback( - async (category: Category) => { - if (!currentTransaction || animationState !== 'idle') return; - - setLastSelectedCategory(category); - setAnimationState('exiting'); - - try { - await transactionSyncService.update(currentTransaction.id, { - category_id: category.id, - }); - - updateCategoryUsageOrder(category.id); - setCategoryUsageOrder(getCategoryUsageOrder()); - } catch (error) { - console.error('Failed to update transaction:', error); - setAnimationState('idle'); - return; - } - - setTimeout(() => { - setAnimationState('success'); - - setTimeout(() => { - setCurrentIndex((prev) => prev + 1); - setAnimationState('entering'); - setSearchValue(''); - - setTimeout(() => { - setAnimationState('idle'); - setLastSelectedCategory(null); - commandInputRef.current?.focus(); - }, 300); - }, 400); - }, 300); - }, - [currentTransaction, animationState], - ); - - const handleSkip = useCallback(() => { - if (animationState !== 'idle') return; - - setAnimationState('exiting'); - - setTimeout(() => { - setCurrentIndex((prev) => prev + 1); - setAnimationState('entering'); - - setTimeout(() => { - setAnimationState('idle'); - }, 300); - }, 300); - }, [animationState]); - - const handlePrevious = useCallback(() => { - if (animationState !== 'idle' || currentIndex === 0) return; - - setAnimationState('exiting'); - - setTimeout(() => { - setCurrentIndex((prev) => prev - 1); - setAnimationState('entering'); - - setTimeout(() => { - setAnimationState('idle'); - }, 300); - }, 300); - }, [animationState, currentIndex]); - - // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Ctrl + R for rules dialog if (e.ctrlKey && e.key === 'r') { e.preventDefault(); setRulesDialogOpen(true); } - // Ctrl + N for skip (next) if (e.ctrlKey && e.key === 'n') { e.preventDefault(); if (animationState === 'idle' && currentTransaction) { handleSkip(); } } - // Ctrl + B for previous (back) if (e.ctrlKey && e.key === 'b') { e.preventDefault(); if (animationState === 'idle' && currentIndex > 0) { handlePrevious(); } } - // Escape to go back to transactions if (e.key === 'Escape' && !rulesDialogOpen) { e.preventDefault(); - const transactionsUrl = categorizeRoute - .url() - ?.replace('/categorize', ''); - if (transactionsUrl) { - router.visit(transactionsUrl); + if (backHref) { + router.visit(backHref); } } }; @@ -461,6 +100,8 @@ export default function CategorizeTransactions({ handlePrevious, currentIndex, rulesDialogOpen, + setRulesDialogOpen, + backHref, ]); if (isLoading) { @@ -504,12 +145,7 @@ export default function CategorizeTransactions({ )}

- trigger('light')} - > + trigger('light')}> @@ -628,215 +270,20 @@ export default function CategorizeTransactions({
-
- {animationState === 'success' && - lastSelectedCategory && ( -
-
-
- -
- - {lastSelectedCategory.name} - -
-
- )} - - {animationState !== 'success' && - currentTransaction && ( -
-
-
-
-
-

- {formatDateLong( - currentTransaction.transaction_date, - locale, - )} -

- -

- {currentTransaction.decryptedDescription || - 'Encrypted'} -

- - {currentTransaction.account && ( -
- {currentTransaction - .bank - ?.logo && ( - { - )} - -
- )} -
-
- - {currentTransaction.decryptedNotes && ( -

- { - currentTransaction.decryptedNotes - } -

- )} -
- -
- = - 0 - } - /> -
-
-
- )} -
- - {animationState !== 'success' && currentTransaction && ( -
-
-

- {__('Assign a new category')} -

-

- {__('Search, move')}{' '} - - - - - - - {__(', and press')} - -

-
- - - - - - {__('No categories found.')} - - - {sortedCategories.map( - (category) => { - const colorClasses = - getCategoryColorClasses( - category.color, - ); - return ( - - handleCategorySelect( - category, - ) - } - disabled={ - animationState !== - 'idle' - } - className="group cursor-pointer gap-3 p-2" - > -
- -
- - {category.name} - -
- ); - }, - )} -
-
-
-
- )} + +
@@ -845,78 +292,6 @@ export default function CategorizeTransactions({ open={rulesDialogOpen} onOpenChange={handleRulesDialogClose} /> - - ); } diff --git a/routes/web.php b/routes/web.php index 0fe34cda..a197596b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -50,11 +50,14 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['onboarded'])->group(function () { Route::get('onboarding', [OnboardingController::class, 'index'])->name('onboarding'); + Route::get('onboarding/sync-status', [OnboardingController::class, 'syncStatus'])->name('onboarding.sync-status'); Route::post('onboarding/complete', [OnboardingController::class, 'complete'])->name('onboarding.complete'); }); - // Accessible during onboarding for transaction import + // Accessible during onboarding for transaction import and categorization Route::post('transactions', [TransactionController::class, 'store'])->name('transactions.store'); + Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('transactions.bulk-update'); + Route::patch('transactions/{transaction}', [TransactionController::class, 'update'])->name('transactions.update'); }); Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () { @@ -66,10 +69,8 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(functi Route::get('transactions', [TransactionController::class, 'index'])->name('transactions.index'); Route::get('transactions/categorize', [TransactionController::class, 'categorize'])->name('transactions.categorize'); - Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('transactions.bulk-update'); Route::post('transactions/re-evaluate-rules', [ReEvaluateTransactionRulesController::class, 'bulk'])->name('transactions.re-evaluate-rules.bulk'); Route::get('transactions/re-evaluate-rules/status/{jobId}', [ReEvaluateTransactionRulesController::class, 'status'])->name('transactions.re-evaluate-rules.status'); - Route::patch('transactions/{transaction}', [TransactionController::class, 'update'])->name('transactions.update'); Route::delete('transactions/{transaction}', [TransactionController::class, 'destroy'])->name('transactions.destroy'); Route::post('transactions/{transaction}/re-evaluate-rules', [ReEvaluateTransactionRulesController::class, 'single'])->name('transactions.re-evaluate-rules.single'); }); diff --git a/tests/Browser/OnboardingFlowTest.php b/tests/Browser/OnboardingFlowTest.php index 78ecb692..b49f915b 100644 --- a/tests/Browser/OnboardingFlowTest.php +++ b/tests/Browser/OnboardingFlowTest.php @@ -148,8 +148,8 @@ it('allows continuing with existing accounts', function () { // Click Continue to proceed ->click('Continue') ->wait(2) - // Should go to import transactions (since checking account needs transactions) - ->assertSee('Import Your Transactions') + // Should go to category types (existing accounts no longer trigger import) + ->assertSee('Understanding Categories') ->assertNoJavascriptErrors(); }); @@ -180,9 +180,8 @@ it('shows import transactions step after account creation', function () { ->wait(1) ->click('Continue') ->wait(2) - // Should show import transactions step - ->assertSee('Import Your Transactions') - ->assertSee('Import Transactions') + // Should go to category types (existing accounts no longer trigger import) + ->assertSee('Understanding Categories') ->assertNoJavascriptErrors(); }); @@ -296,7 +295,12 @@ it('completes entire onboarding flow with account creation, transaction import, ->click('Import 5 Transactions') ->wait(15); - // After import completes, drawer closes and transitions to Category Types + // After import completes, back to create-account step in list mode + $page->assertSee('My Checking Account') + ->click('Continue') + ->wait(1); + + // Category Types $page->assertSee('Understanding Categories') ->click('Continue') ->wait(1); @@ -306,10 +310,10 @@ it('completes entire onboarding flow with account creation, transaction import, ->click('Continue') ->wait(1); - // More Accounts - verify account is listed, then finish - $page->assertSee('Great Progress!') - ->assertSee('My Checking Account') - ->click('Finish Setup') + // Categorize Transactions - transactions prop was loaded at page start (before import), + // so no uncategorized transactions are available and Continue is immediately enabled + $page->assertSee('No Uncategorized Transactions') + ->click('Continue') ->wait(1); // Complete step diff --git a/tests/Feature/Onboarding/OnboardingControllerTest.php b/tests/Feature/Onboarding/OnboardingControllerTest.php new file mode 100644 index 00000000..6da2314c --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingControllerTest.php @@ -0,0 +1,97 @@ +create(['onboarded_at' => null]); + $bank = Bank::factory()->create(); + $account = Account::factory()->create(['user_id' => $user->id, 'bank_id' => $bank->id]); + $category = Category::factory()->create(['user_id' => $user->id]); + + // One uncategorized and one categorized transaction + $uncategorized = Transaction::factory()->plaintext()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'category_id' => null, + ]); + Transaction::factory()->plaintext()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'category_id' => $category->id, + ]); + + $response = $this->actingAs($user)->get('/onboarding'); + + $response->assertSuccessful() + ->assertInertia(fn ($page) => $page + ->component('onboarding/index') + ->has('categories', 1) + ->has('transactions', 1) + ->where('transactions.0.id', $uncategorized->id) + ); +}); + +it('returns only uncategorized transactions in the transactions prop', function () { + $user = User::factory()->create(['onboarded_at' => null]); + $bank = Bank::factory()->create(); + $account = Account::factory()->create(['user_id' => $user->id, 'bank_id' => $bank->id]); + $category = Category::factory()->create(['user_id' => $user->id]); + + Transaction::factory()->plaintext()->count(3)->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'category_id' => null, + ]); + Transaction::factory()->plaintext()->count(2)->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'category_id' => $category->id, + ]); + + $response = $this->actingAs($user)->get('/onboarding'); + + $response->assertSuccessful() + ->assertInertia(fn ($page) => $page + ->has('transactions', 3) + ); +}); + +it('does not return transactions belonging to other users', function () { + $user = User::factory()->create(['onboarded_at' => null]); + $other = User::factory()->create(['onboarded_at' => null]); + $bank = Bank::factory()->create(); + $account = Account::factory()->create(['user_id' => $other->id, 'bank_id' => $bank->id]); + + Transaction::factory()->plaintext()->create([ + 'user_id' => $other->id, + 'account_id' => $account->id, + 'category_id' => null, + ]); + + $response = $this->actingAs($user)->get('/onboarding'); + + $response->assertSuccessful() + ->assertInertia(fn ($page) => $page + ->has('transactions', 0) + ); +}); + +it('returns banks and accounts props on onboarding index', function () { + $user = User::factory()->create(['onboarded_at' => null]); + $globalBank = Bank::factory()->create(['user_id' => null]); + $userBank = Bank::factory()->create(['user_id' => $user->id]); + $otherBank = Bank::factory()->create(['user_id' => User::factory()->create()->id]); + + $response = $this->actingAs($user)->get('/onboarding'); + + $response->assertSuccessful() + ->assertInertia(fn ($page) => $page + ->component('onboarding/index') + ->has('banks', 2) // global + user's own bank + ->has('accounts') + ); +}); diff --git a/tests/Feature/Onboarding/OnboardingSyncStatusTest.php b/tests/Feature/Onboarding/OnboardingSyncStatusTest.php new file mode 100644 index 00000000..c76051ed --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingSyncStatusTest.php @@ -0,0 +1,89 @@ +create(['onboarded_at' => null]); + + $this->actingAs($user) + ->getJson('/onboarding/sync-status') + ->assertOk() + ->assertJson(['pending' => false]); +}); + +it('returns pending false when all banking connections have been synced', function () { + $user = User::factory()->create(['onboarded_at' => null]); + + BankingConnection::factory()->for($user)->create([ + 'status' => BankingConnectionStatus::Active, + 'last_synced_at' => now(), + ]); + + $this->actingAs($user) + ->getJson('/onboarding/sync-status') + ->assertOk() + ->assertJson(['pending' => false]); +}); + +it('returns pending true when an active connection has not been synced yet', function () { + $user = User::factory()->create(['onboarded_at' => null]); + + BankingConnection::factory()->for($user)->create([ + 'status' => BankingConnectionStatus::Active, + 'last_synced_at' => null, + ]); + + $this->actingAs($user) + ->getJson('/onboarding/sync-status') + ->assertOk() + ->assertJson(['pending' => true]); +}); + +it('returns pending false when unsynced connection has an error status', function () { + $user = User::factory()->create(['onboarded_at' => null]); + + BankingConnection::factory()->for($user)->error()->create([ + 'last_synced_at' => null, + ]); + + $this->actingAs($user) + ->getJson('/onboarding/sync-status') + ->assertOk() + ->assertJson(['pending' => false]); +}); + +it('returns pending false when unsynced connection is revoked', function () { + $user = User::factory()->create(['onboarded_at' => null]); + + BankingConnection::factory()->for($user)->revoked()->create([ + 'last_synced_at' => null, + ]); + + $this->actingAs($user) + ->getJson('/onboarding/sync-status') + ->assertOk() + ->assertJson(['pending' => false]); +}); + +it('requires authentication', function () { + $this->getJson('/onboarding/sync-status') + ->assertUnauthorized(); +}); + +it('only considers the authenticated users connections', function () { + $user = User::factory()->create(['onboarded_at' => null]); + $other = User::factory()->create(['onboarded_at' => null]); + + // Other user has a pending sync — should not affect our user + BankingConnection::factory()->for($other)->create([ + 'status' => BankingConnectionStatus::Active, + 'last_synced_at' => null, + ]); + + $this->actingAs($user) + ->getJson('/onboarding/sync-status') + ->assertOk() + ->assertJson(['pending' => false]); +});