From 6abec95d0eee266e1a4570400bd82d2e5228695e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Mon, 16 Feb 2026 10:37:43 +0100 Subject: [PATCH] feat: Decrypt encrypted transactions on key unlock (#123) ## Summary - Add `GET /api/transactions?encrypted=true` endpoint for paginated listing of encrypted transactions - Add `PATCH /api/transactions/bulk` endpoint for batch-updating decrypted transaction data (max 50 per request, bypasses model events) - Add `useDecryptTransactions` hook that mirrors the existing account name decryption flow: fetches encrypted transactions page-by-page, decrypts `description`/`notes` client-side via Web Crypto API, sends plaintext back and clears IVs - Wire the hook into `AppSidebarLayout` so decryption runs automatically when the user unlocks their encryption key - Once all transactions (and accounts) are decrypted, the encryption key button in the header disappears automatically ## Test plan - [x] 11 Pest tests covering encrypted/plaintext filtering, pagination, user scoping, bulk update, authorization, validation, nullable notes, guest access, and no-model-events behavior - [x] Manual: create encrypted transactions, unlock encryption key, verify transactions get decrypted and the key button disappears --- .github/copilot-instructions.md | 329 ++++++++++++++++ .../skills/inertia-react-development/SKILL.md | 369 ++++++++++++++++++ .../skills/pennant-development/SKILL.md | 74 ++++ .github/copilot/skills/pest-testing/SKILL.md | 174 +++++++++ .../skills/tailwindcss-development/SKILL.md | 124 ++++++ .../skills/wayfinder-development/SKILL.md | 89 +++++ AGENTS.md | 2 +- CLAUDE.md | 6 +- .../Controllers/Api/TransactionController.php | 62 +++ .../Settings/AccountController.php | 4 +- app/Http/Middleware/HandleInertiaRequests.php | 14 + .../Api/BulkUpdateTransactionRequest.php | 34 ++ boost.json | 4 +- resources/js/app.tsx | 11 +- .../js/components/app-sidebar-header.tsx | 12 +- .../onboarding/step-more-accounts.tsx | 7 +- .../js/contexts/encryption-key-context.tsx | 4 + .../js/hooks/use-decrypt-transactions.ts | 111 ++++++ .../js/layouts/app/app-sidebar-layout.tsx | 2 + resources/js/services/transaction-sync.ts | 32 +- resources/js/types/index.d.ts | 1 + routes/api.php | 5 + routes/web.php | 4 +- tests/Browser/OnboardingFlowTest.php | 119 +++--- tests/Feature/DecryptTransactionsTest.php | 235 +++++++++++ 25 files changed, 1750 insertions(+), 78 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/copilot/skills/inertia-react-development/SKILL.md create mode 100644 .github/copilot/skills/pennant-development/SKILL.md create mode 100644 .github/copilot/skills/pest-testing/SKILL.md create mode 100644 .github/copilot/skills/tailwindcss-development/SKILL.md create mode 100644 .github/copilot/skills/wayfinder-development/SKILL.md create mode 100644 app/Http/Controllers/Api/TransactionController.php create mode 100644 app/Http/Requests/Api/BulkUpdateTransactionRequest.php create mode 100644 resources/js/hooks/use-decrypt-transactions.ts create mode 100644 tests/Feature/DecryptTransactionsTest.php diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..10e0b2ca --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,329 @@ + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. + +## Foundational Context + +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4.17 +- inertiajs/inertia-laravel (INERTIA) - v2 +- laravel/cashier (CASHIER) - v16 +- laravel/fortify (FORTIFY) - v1 +- laravel/framework (LARAVEL) - v12 +- laravel/pennant (PENNANT) - v1 +- laravel/prompts (PROMPTS) - v0 +- laravel/wayfinder (WAYFINDER) - v0 +- laravel/mcp (MCP) - v0 +- laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 +- pestphp/pest (PEST) - v4 +- phpunit/phpunit (PHPUNIT) - v12 +- @inertiajs/react (INERTIA) - v2 +- react (REACT) - v19 +- tailwindcss (TAILWINDCSS) - v4 +- @laravel/vite-plugin-wayfinder (WAYFINDER) - v0 +- eslint (ESLINT) - v9 +- prettier (PRETTIER) - v3 + +## Skills Activation + +This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +- `pennant-development` — Manages feature flags with Laravel Pennant. Activates when creating, checking, or toggling feature flags; showing or hiding features conditionally; implementing A/B testing; working with @feature directive; or when the user mentions feature flags, feature toggles, Pennant, conditional features, rollouts, or gradually enabling features. +- `wayfinder-development` — Activates whenever referencing backend routes in frontend components. Use when importing from @/actions or @/routes, calling Laravel routes from TypeScript, or working with Wayfinder route functions. +- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works. +- `inertia-react-development` — Develops Inertia.js v2 React client-side applications. Activates when creating React pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions React with Inertia, React pages, React forms, or React navigation. +- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes. + +## Conventions + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. + +## Application Structure & Architecture + +- Stick to existing directory structure; don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling + +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Documentation Files + +- You must only create documentation files if explicitly requested by the user. + +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +=== boost rules === + +# Laravel Boost + +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan + +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters. + +## URLs + +- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port. + +## Tinker / Debugging + +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool + +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) + +- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first. +- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'. +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit". +3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order. +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit". +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms. + +=== php rules === + +# PHP + +- Always use curly braces for control structures, even for single-line bodies. + +## Constructors + +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private. + +## Type Declarations + +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Enums + +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + +## Comments + +- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex. + +## PHPDoc Blocks + +- Add useful array shape type definitions when appropriate. + +=== tests rules === + +# Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. + +=== inertia-laravel/core rules === + +# Inertia + +- Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns. +- Components live in `resources/js/Pages` (unless specified in `vite.config.js`). Use `Inertia::render()` for server-side routing instead of Blade views. +- ALWAYS use `search-docs` tool for version-specific Inertia documentation and updated code examples. +- IMPORTANT: Activate `inertia-react-development` when working with Inertia client-side patterns. + +=== inertia-laravel/v2 rules === + +# Inertia v2 + +- Use all Inertia features from v1 and v2. Check the documentation before making changes to ensure the correct approach. +- New features: deferred props, infinite scrolling (merging props + `WhenVisible`), lazy loading on scroll, polling, prefetching. +- When using deferred props, add an empty state with a pulsing or animated skeleton. + +=== laravel/core rules === + +# Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `php artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +## Database + +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries. +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation + +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources + +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +## Controllers & Validation + +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +## Authentication & Authorization + +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +## URL Generation + +- When generating links to other pages, prefer named routes and the `route()` function. + +## Queues + +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +## Configuration + +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +## Testing + +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +## Vite Error + +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + +=== laravel/v12 rules === + +# Laravel 12 + +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +## Laravel 12 Structure + +- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. +- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. + +## Database + +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models + +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + +=== pennant/core rules === + +# Laravel Pennant + +- This application uses Laravel Pennant for feature flag management, providing a flexible system for controlling feature availability across different organizations and user types. +- IMPORTANT: Always use `search-docs` tool for version-specific Pennant documentation and updated code examples. +- IMPORTANT: Activate `pennant-development` every time you're working with a Pennant or feature-flag-related task. + +=== wayfinder/core rules === + +# Laravel Wayfinder + +Wayfinder generates TypeScript functions for Laravel routes. Import from `@/actions/` (controllers) or `@/routes/` (named routes). + +- IMPORTANT: Activate `wayfinder-development` skill whenever referencing backend routes in frontend components. +- Invokable Controllers: `import StorePost from '@/actions/.../StorePostController'; StorePost()`. +- Parameter Binding: Detects route keys (`{post:slug}`) — `show({ slug: "my-post" })`. +- Query Merging: `show(1, { mergeQuery: { page: 2, sort: null } })` merges with current URL, `null` removes params. +- Inertia: Use `.form()` with `
` component or `form.submit(store())` with useForm. + +=== pint/core rules === + +# Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + +=== pest/core rules === + +## Pest + +- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. +- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. +- Do NOT delete tests without approval. +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. +- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. + +=== inertia-react/core rules === + +# Inertia + React + +- IMPORTANT: Activate `inertia-react-development` when working with Inertia React client-side patterns. + +=== tailwindcss/core rules === + +# Tailwind CSS + +- Always use existing Tailwind conventions; check project patterns before adding new ones. +- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data. +- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task. + +=== laravel/fortify rules === + +## Laravel Fortify + +Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. + +**Before implementing any authentication features, use the `search-docs` tool to get the latest docs for that specific feature.** + +### Configuration & Setup + +- Check `config/fortify.php` to see what's enabled. Use `search-docs` for detailed information on specific features. +- Enable features by adding them to the `'features' => []` array: `Features::registration()`, `Features::resetPasswords()`, etc. +- To see the all Fortify registered routes, use the `list-routes` tool with the `only_vendor: true` and `action: "Fortify"` parameters. +- Fortify includes view routes by default (login, register). Set `'views' => false` in the configuration file to disable them if you're handling views yourself. + +### Customization + +- Views can be customized in `FortifyServiceProvider`'s `boot()` method using `Fortify::loginView()`, `Fortify::registerView()`, etc. +- Customize authentication logic with `Fortify::authenticateUsing()` for custom user retrieval / validation. +- Actions in `app/Actions/Fortify/` handle business logic (user creation, password reset, etc.). They're fully customizable, so you can modify them to change feature behavior. + +## Available Features + +- `Features::registration()` for user registration. +- `Features::emailVerification()` to verify new user emails. +- `Features::twoFactorAuthentication()` for 2FA with QR codes and recovery codes. + - Add options: `['confirmPassword' => true, 'confirm' => true]` to require password confirmation and OTP confirmation before enabling 2FA. +- `Features::updateProfileInformation()` to let users update their profile. +- `Features::updatePasswords()` to let users change their passwords. +- `Features::resetPasswords()` for password reset via email. + diff --git a/.github/copilot/skills/inertia-react-development/SKILL.md b/.github/copilot/skills/inertia-react-development/SKILL.md new file mode 100644 index 00000000..dcd104f9 --- /dev/null +++ b/.github/copilot/skills/inertia-react-development/SKILL.md @@ -0,0 +1,369 @@ +--- +name: inertia-react-development +description: >- + Develops Inertia.js v2 React client-side applications. Activates when creating + React pages, forms, or navigation; using , , useForm, or router; + working with deferred props, prefetching, or polling; or when user mentions + React with Inertia, React pages, React forms, or React navigation. +--- + +# Inertia React Development + +## When to Apply + +Activate this skill when: + +- Creating or modifying React page components for Inertia +- Working with forms in React (using `` or `useForm`) +- Implementing client-side navigation with `` or `router` +- Using v2 features: deferred props, prefetching, or polling +- Building React-specific features with the Inertia protocol + +## Documentation + +Use `search-docs` for detailed Inertia v2 React patterns and documentation. + +## Basic Usage + +### Page Components Location + +React page components should be placed in the `resources/js/Pages` directory. + +### Page Component Structure + + + +export default function UsersIndex({ users }) { + return ( +
+

Users

+
    + {users.map(user =>
  • {user.name}
  • )} +
+
+ ) +} + +
+ +## Client-Side Navigation + +### Basic Link Component + +Use `` for client-side navigation instead of traditional `` tags: + + + +import { Link, router } from '@inertiajs/react' + +Home +Users +View User + + + +### Link with Method + + + +import { Link } from '@inertiajs/react' + + + Logout + + + + +### Prefetching + +Prefetch pages to improve perceived performance: + + + +import { Link } from '@inertiajs/react' + + + Users + + + + +### Programmatic Navigation + + + +import { router } from '@inertiajs/react' + +function handleClick() { + router.visit('/users') +} + +// Or with options +router.visit('/users', { + method: 'post', + data: { name: 'John' }, + onSuccess: () => console.log('Success!'), +}) + + + +## Form Handling + +### Form Component (Recommended) + +The recommended way to build forms is with the `` component: + + + +import { Form } from '@inertiajs/react' + +export default function CreateUser() { + return ( + + {({ errors, processing, wasSuccessful }) => ( + <> + + {errors.name &&
{errors.name}
} + + + {errors.email &&
{errors.email}
} + + + + {wasSuccessful &&
User created!
} + + )} + + ) +} + +
+ +### Form Component With All Props + + + +import { Form } from '@inertiajs/react' + +
+ {({ + errors, + hasErrors, + processing, + progress, + wasSuccessful, + recentlySuccessful, + clearErrors, + resetAndClearErrors, + defaults, + isDirty, + reset, + submit + }) => ( + <> + + {errors.name &&
{errors.name}
} + + + + {progress && ( + + {progress.percentage}% + + )} + + {wasSuccessful &&
Saved!
} + + )} +
+ +
+ +### Form Component Reset Props + +The `
` component supports automatic resetting: + +- `resetOnError` - Reset form data when the request fails +- `resetOnSuccess` - Reset form data when the request succeeds +- `setDefaultsOnSuccess` - Update default values on success + +Use the `search-docs` tool with a query of `form component resetting` for detailed guidance. + + + +import { Form } from '@inertiajs/react' + + + {({ errors, processing, wasSuccessful }) => ( + <> + + {errors.name &&
{errors.name}
} + + + + )} + + +
+ +Forms can also be built using the `useForm` helper for more programmatic control. Use the `search-docs` tool with a query of `useForm helper` for guidance. + +### `useForm` Hook + +For more programmatic control or to follow existing conventions, use the `useForm` hook: + + + +import { useForm } from '@inertiajs/react' + +export default function CreateUser() { + const { data, setData, post, processing, errors, reset } = useForm({ + name: '', + email: '', + password: '', + }) + + function submit(e) { + e.preventDefault() + post('/users', { + onSuccess: () => reset('password'), + }) + } + + return ( +
+ setData('name', e.target.value)} + /> + {errors.name &&
{errors.name}
} + + setData('email', e.target.value)} + /> + {errors.email &&
{errors.email}
} + + setData('password', e.target.value)} + /> + {errors.password &&
{errors.password}
} + + +
+ ) +} + +
+ +## Inertia v2 Features + +### Deferred Props + +Use deferred props to load data after initial page render: + + + +export default function UsersIndex({ users }) { + // users will be undefined initially, then populated + return ( +
+

Users

+ {!users ? ( +
+
+
+
+ ) : ( +
    + {users.map(user => ( +
  • {user.name}
  • + ))} +
+ )} +
+ ) +} + +
+ +### Polling + +Automatically refresh data at intervals: + + + +import { router } from '@inertiajs/react' +import { useEffect } from 'react' + +export default function Dashboard({ stats }) { + useEffect(() => { + const interval = setInterval(() => { + router.reload({ only: ['stats'] }) + }, 5000) // Poll every 5 seconds + + return () => clearInterval(interval) + }, []) + + return ( +
+

Dashboard

+
Active Users: {stats.activeUsers}
+
+ ) +} + +
+ +### WhenVisible (Infinite Scroll) + +Load more data when user scrolls to a specific element: + + + +import { WhenVisible } from '@inertiajs/react' + +export default function UsersList({ users }) { + return ( +
+ {users.data.map(user => ( +
{user.name}
+ ))} + + {users.next_page_url && ( + Loading more...
} + /> + )} + + ) +} + +
+ +## Common Pitfalls + +- Using traditional `
` links instead of Inertia's `` component (breaks SPA behavior) +- Forgetting to add loading states (skeleton screens) when using deferred props +- Not handling the `undefined` state of deferred props before data loads +- Using `
` without preventing default submission (use `` component or `e.preventDefault()`) +- Forgetting to check if `` component is available in your Inertia version \ No newline at end of file diff --git a/.github/copilot/skills/pennant-development/SKILL.md b/.github/copilot/skills/pennant-development/SKILL.md new file mode 100644 index 00000000..6a18371e --- /dev/null +++ b/.github/copilot/skills/pennant-development/SKILL.md @@ -0,0 +1,74 @@ +--- +name: pennant-development +description: >- + Manages feature flags with Laravel Pennant. Activates when creating, checking, or toggling + feature flags; showing or hiding features conditionally; implementing A/B testing; working with + @feature directive; or when the user mentions feature flags, feature toggles, Pennant, conditional + features, rollouts, or gradually enabling features. +--- + +# Pennant Features + +## When to Apply + +Activate this skill when: + +- Creating or checking feature flags +- Managing feature rollouts +- Implementing A/B testing + +## Documentation + +Use `search-docs` for detailed Pennant patterns and documentation. + +## Basic Usage + +### Defining Features + + +use Laravel\Pennant\Feature; + +Feature::define('new-dashboard', function (User $user) { + return $user->isAdmin(); +}); + + +### Checking Features + + +if (Feature::active('new-dashboard')) { + // Feature is active +} + +// With scope +if (Feature::for($user)->active('new-dashboard')) { + // Feature is active for this user +} + + +### Blade Directive + + +@feature('new-dashboard') + +@else + +@endfeature + + +### Activating / Deactivating + + +Feature::activate('new-dashboard'); +Feature::for($user)->activate('new-dashboard'); + + +## Verification + +1. Check feature flag is defined +2. Test with different scopes/users + +## Common Pitfalls + +- Forgetting to scope features for specific users/entities +- Not following existing naming conventions \ No newline at end of file diff --git a/.github/copilot/skills/pest-testing/SKILL.md b/.github/copilot/skills/pest-testing/SKILL.md new file mode 100644 index 00000000..da770013 --- /dev/null +++ b/.github/copilot/skills/pest-testing/SKILL.md @@ -0,0 +1,174 @@ +--- +name: pest-testing +description: >- + Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature + tests, adding assertions, testing Livewire components, browser testing, debugging test failures, + working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, + coverage, or needs to verify functionality works. +--- + +# Pest Testing 4 + +## When to Apply + +Activate this skill when: + +- Creating new tests (unit, feature, or browser) +- Modifying existing tests +- Debugging test failures +- Working with browser testing or smoke testing +- Writing architecture tests or visual regression tests + +## Documentation + +Use `search-docs` for detailed Pest 4 patterns and documentation. + +## Basic Usage + +### Creating Tests + +All tests must be written using Pest. Use `php artisan make:test --pest {name}`. + +### Test Organization + +- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories. +- Browser tests: `tests/Browser/` directory. +- Do NOT remove tests without approval - these are core application code. + +### Basic Test Structure + + + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + + +### Running Tests + +- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`. +- Run all tests: `php artisan test --compact`. +- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`. + +## Assertions + +Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`: + + + +it('returns all', function () { + $this->postJson('/api/docs', [])->assertSuccessful(); +}); + + + +| Use | Instead of | +|-----|------------| +| `assertSuccessful()` | `assertStatus(200)` | +| `assertNotFound()` | `assertStatus(404)` | +| `assertForbidden()` | `assertStatus(403)` | + +## Mocking + +Import mock function before use: `use function Pest\Laravel\mock;` + +## Datasets + +Use datasets for repetitive tests (validation rules, etc.): + + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +## Pest 4 Features + +| Feature | Purpose | +|---------|---------| +| Browser Testing | Full integration tests in real browsers | +| Smoke Testing | Validate multiple pages quickly | +| Visual Regression | Compare screenshots for visual changes | +| Test Sharding | Parallel CI runs | +| Architecture Testing | Enforce code conventions | + +### Browser Test Example + +Browser tests run in real browsers for full integration testing: + +- Browser tests live in `tests/Browser/`. +- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories. +- Use `RefreshDatabase` for clean state per test. +- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures. +- Test on multiple browsers (Chrome, Firefox, Safari) if requested. +- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested. +- Switch color schemes (light/dark mode) when appropriate. +- Take screenshots or pause tests for debugging. + + + +it('may reset the password', function () { + Notification::fake(); + + $this->actingAs(User::factory()->create()); + + $page = visit('/sign-in'); + + $page->assertSee('Sign In') + ->assertNoJavascriptErrors() + ->click('Forgot Password?') + ->fill('email', 'nuno@laravel.com') + ->click('Send Reset Link') + ->assertSee('We have emailed your password reset link!'); + + Notification::assertSent(ResetPassword::class); +}); + + + +### Smoke Testing + +Quickly validate multiple pages have no JavaScript errors: + + + +$pages = visit(['/', '/about', '/contact']); + +$pages->assertNoJavascriptErrors()->assertNoConsoleLogs(); + + + +### Visual Regression Testing + +Capture and compare screenshots to detect visual changes. + +### Test Sharding + +Split tests across parallel processes for faster CI runs. + +### Architecture Testing + +Pest 4 includes architecture testing (from Pest 3): + + + +arch('controllers') + ->expect('App\Http\Controllers') + ->toExtendNothing() + ->toHaveSuffix('Controller'); + + + +## Common Pitfalls + +- Not importing `use function Pest\Laravel\mock;` before using mock +- Using `assertStatus(200)` instead of `assertSuccessful()` +- Forgetting datasets for repetitive validation tests +- Deleting tests without approval +- Forgetting `assertNoJavascriptErrors()` in browser tests \ No newline at end of file diff --git a/.github/copilot/skills/tailwindcss-development/SKILL.md b/.github/copilot/skills/tailwindcss-development/SKILL.md new file mode 100644 index 00000000..12bd896b --- /dev/null +++ b/.github/copilot/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,124 @@ +--- +name: tailwindcss-development +description: >- + Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, + working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, + typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, + hero section, cards, buttons, or any visual/UI changes. +--- + +# Tailwind CSS Development + +## When to Apply + +Activate this skill when: + +- Adding styles to components or pages +- Working with responsive design +- Implementing dark mode +- Extracting repeated patterns into components +- Debugging spacing or layout issues + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +@theme { + --color-brand: oklch(0.72 0.11 178); +} + + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; + + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +
+
Item 1
+
Item 2
+
+
+ +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +
+ Content adapts to color scheme +
+
+ +## Common Patterns + +### Flexbox Layout + + +
+
Left content
+
Right content
+
+
+ +### Grid Layout + + +
+
Card 1
+
Card 2
+
Card 3
+
+
+ +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode \ No newline at end of file diff --git a/.github/copilot/skills/wayfinder-development/SKILL.md b/.github/copilot/skills/wayfinder-development/SKILL.md new file mode 100644 index 00000000..d8d586e2 --- /dev/null +++ b/.github/copilot/skills/wayfinder-development/SKILL.md @@ -0,0 +1,89 @@ +--- +name: wayfinder-development +description: >- + Activates whenever referencing backend routes in frontend components. Use when + importing from @/actions or @/routes, calling Laravel routes from TypeScript, + or working with Wayfinder route functions. +--- + +# Wayfinder Development + +## When to Apply + +Activate whenever referencing backend routes in frontend components: +- Importing from `@/actions/` or `@/routes/` +- Calling Laravel routes from TypeScript/JavaScript +- Creating links or navigation to backend endpoints + +## Documentation + +Use `search-docs` for detailed Wayfinder patterns and documentation. + +## Quick Reference + +### Generate Routes + +Run after route changes if Vite plugin isn't installed: + +php artisan wayfinder:generate --no-interaction + +For form helpers, use `--with-form` flag: + +php artisan wayfinder:generate --with-form --no-interaction + +### Import Patterns + + + +// Named imports for tree-shaking (preferred)... +import { show, store, update } from '@/actions/App/Http/Controllers/PostController' + +// Named route imports... +import { show as postShow } from '@/routes/post' + + + +### Common Methods + + + +// Get route object... +show(1) // { url: "/posts/1", method: "get" } + +// Get URL string... +show.url(1) // "/posts/1" + +// Specific HTTP methods... +show.get(1) +store.post() +update.patch(1) +destroy.delete(1) + +// Form attributes for HTML forms... +store.form() // { action: "/posts", method: "post" } + +// Query parameters... +show(1, { query: { page: 1 } }) // "/posts/1?page=1" + + + +## Wayfinder + Inertia + +Use Wayfinder with the `` component: + + + + +
+ +## Verification + +1. Run `php artisan wayfinder:generate` to regenerate routes if Vite plugin isn't installed +2. Check TypeScript imports resolve correctly +3. Verify route URLs match expected paths + +## Common Pitfalls + +- Using default imports instead of named imports (breaks tree-shaking) +- Forgetting to regenerate after route changes +- Not using type-safe parameter objects for route model binding \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 72e92e0f..10e0b2ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -- php - 8.4.1 +- php - 8.4.17 - inertiajs/inertia-laravel (INERTIA) - v2 - laravel/cashier (CASHIER) - v16 - laravel/fortify (FORTIFY) - v1 diff --git a/CLAUDE.md b/CLAUDE.md index 61a43e67..4f9ee064 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. -- php - 8.4.1 +- php - 8.4.17 - inertiajs/inertia-laravel (INERTIA) - v2 - laravel/cashier (CASHIER) - v16 - laravel/fortify (FORTIFY) - v1 @@ -210,7 +210,7 @@ This project has domain-specific skills available. You MUST activate the relevan ## Constructors - Use PHP 8 constructor property promotion in `__construct()`. - - public function \_\_construct(public GitHub $github) { } + - public function __construct(public GitHub $github) { } - Do not allow empty `__construct()` methods with zero parameters unless the constructor is private. ## Type Declarations @@ -422,4 +422,4 @@ Fortify is a headless authentication backend that provides authentication routes - `Features::updateProfileInformation()` to let users update their profile. - `Features::updatePasswords()` to let users change their passwords. - `Features::resetPasswords()` for password reset via email. -
+ diff --git a/app/Http/Controllers/Api/TransactionController.php b/app/Http/Controllers/Api/TransactionController.php new file mode 100644 index 00000000..89cb9aa9 --- /dev/null +++ b/app/Http/Controllers/Api/TransactionController.php @@ -0,0 +1,62 @@ +user() + ->transactions() + ->with('labels:id,name,color'); + + if ($request->query('encrypted') === 'true') { + $query->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv')); + } elseif ($request->query('encrypted') === 'false') { + $query->whereNull('description_iv')->whereNull('notes_iv'); + } + + $transactions = $query->simplePaginate(100); + + return response()->json($transactions); + } + + /** + * Bulk update transactions (used for decryption migration). + */ + public function bulkUpdate(BulkUpdateTransactionRequest $request): JsonResponse + { + $validated = $request->validated(); + + $transactionIds = collect($validated['transactions'])->pluck('id'); + + $userTransactionIds = $request->user() + ->transactions() + ->whereIn('id', $transactionIds) + ->pluck('id'); + + if ($userTransactionIds->count() !== $transactionIds->count()) { + abort(403, 'Some transactions do not belong to the authenticated user.'); + } + + foreach ($validated['transactions'] as $data) { + $updateData = collect($data)->except('id')->toArray(); + + Transaction::query() + ->where('id', $data['id']) + ->toBase() + ->update($updateData); + } + + return response()->json(['message' => 'Transactions updated successfully.']); + } +} diff --git a/app/Http/Controllers/Settings/AccountController.php b/app/Http/Controllers/Settings/AccountController.php index 37f3ce5c..b0d04306 100644 --- a/app/Http/Controllers/Settings/AccountController.php +++ b/app/Http/Controllers/Settings/AccountController.php @@ -44,8 +44,8 @@ class AccountController extends Controller 'name_iv' => null, ]); - // Set user's currency_code from first account if not already set - if (is_null($user->currency_code)) { + // Set user's currency_code from first account + if ($user->accounts()->count() === 1) { $user->update(['currency_code' => $account->currency_code]); } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 38b6edb9..27997f18 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -43,6 +43,17 @@ class HandleInertiaRequests extends Middleware $isDemoAccount = $user?->isDemoAccount() && ! app()->environment('local') ?? false; $isDemoQuery = $request->query('demo') === '1'; + // Clean up encryption data if no encrypted accounts or transactions remain + if (! $request->is('api/*') && $user?->encryption_salt !== null) { + $hasAnyEncryptedData = $user->accounts()->where('encrypted', true)->exists() + || $user->transactions()->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv'))->exists(); + + if (! $hasAnyEncryptedData) { + $user->encryptedMessage()->delete(); + $user->update(['encryption_salt' => null]); + } + } + return [ ...parent::share($request), 'flash' => [ @@ -101,6 +112,9 @@ class HandleInertiaRequests extends Middleware ->get(['id', 'name', 'color']) : [], 'hasEncryptedAccounts' => $user?->accounts()->where('encrypted', true)->exists() ?? false, 'hasEncryptionSetup' => $user?->encryption_salt !== null, + 'hasEncryptedTransactions' => $user?->transactions() + ->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv')) + ->exists() ?? false, 'locale' => app()->getLocale(), 'translations' => $this->getTranslations(), ]; diff --git a/app/Http/Requests/Api/BulkUpdateTransactionRequest.php b/app/Http/Requests/Api/BulkUpdateTransactionRequest.php new file mode 100644 index 00000000..ee43fa9f --- /dev/null +++ b/app/Http/Requests/Api/BulkUpdateTransactionRequest.php @@ -0,0 +1,34 @@ +|string> + */ + public function rules(): array + { + return [ + 'transactions' => ['required', 'array', 'min:1', 'max:50'], + 'transactions.*.id' => ['required', 'uuid'], + 'transactions.*.description' => ['sometimes', 'string'], + 'transactions.*.notes' => ['sometimes', 'nullable', 'string'], + 'transactions.*.description_iv' => ['sometimes', 'nullable', 'string'], + 'transactions.*.notes_iv' => ['sometimes', 'nullable', 'string'], + ]; + } +} diff --git a/boost.json b/boost.json index b2e2e13b..fd70969c 100644 --- a/boost.json +++ b/boost.json @@ -1,8 +1,8 @@ { "agents": [ "claude_code", - "cursor", - "opencode" + "opencode", + "copilot" ], "guidelines": true, "herd_mcp": false, diff --git a/resources/js/app.tsx b/resources/js/app.tsx index cd729b88..1417bb16 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -58,6 +58,10 @@ createInertiaApp({ const initialIsAuthenticated = Boolean(initialUser); const hasEncryptionSetup = (initialPageProps?.hasEncryptionSetup as boolean) ?? false; + const hasEncryptedAccounts = + (initialPageProps?.hasEncryptedAccounts as boolean) ?? false; + const hasEncryptedTransactions = + (initialPageProps?.hasEncryptedTransactions as boolean) ?? false; // Initialize translations from server-rendered page data setTranslations( @@ -74,7 +78,12 @@ createInertiaApp({ root.render( - + ().props; + const { + hasEncryptionSetup, + hasEncryptedAccounts, + hasEncryptedTransactions, + } = usePage().props; + + const showEncryptionButton = + hasEncryptionSetup && + (hasEncryptedTransactions || hasEncryptedAccounts); return (
@@ -29,7 +37,7 @@ export function AppSidebarHeader({
- {hasEncryptionSetup && ( + {showEncryptionButton && ( <> 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?', ); @@ -70,7 +75,7 @@ export function StepMoreAccounts({
))} - {existingAccounts.map((account) => ( + {filteredExistingAccounts.map((account) => (
( '/api/encryption/message', diff --git a/resources/js/hooks/use-decrypt-transactions.ts b/resources/js/hooks/use-decrypt-transactions.ts new file mode 100644 index 00000000..db8f1b27 --- /dev/null +++ b/resources/js/hooks/use-decrypt-transactions.ts @@ -0,0 +1,111 @@ +import { useEncryptionKey } from '@/contexts/encryption-key-context'; +import { decrypt, importKey } from '@/lib/crypto'; +import { getStoredKey } from '@/lib/key-storage'; +import { SharedData } from '@/types'; +import { usePage } from '@inertiajs/react'; +import axios from 'axios'; +import { useEffect, useRef } from 'react'; + +interface EncryptedTransaction { + id: string; + description: string; + description_iv: string | null; + notes: string | null; + notes_iv: string | null; +} + +interface PaginatedResponse { + data: EncryptedTransaction[]; + next_page_url: string | null; +} + +interface BulkUpdateItem { + id: string; + description?: string; + notes?: string | null; + description_iv: null; + notes_iv: null; +} + +export function useDecryptTransactions() { + const { isKeySet } = useEncryptionKey(); + const { hasEncryptedTransactions } = usePage().props; + const hasRun = useRef(false); + + useEffect(() => { + if (!isKeySet || !hasEncryptedTransactions || hasRun.current) { + return; + } + + hasRun.current = true; + + async function migrateTransactions() { + try { + const keyString = getStoredKey(); + if (!keyString) { + return; + } + + const key = await importKey(keyString); + + let url: string | null = '/api/transactions?encrypted=true'; + + while (url) { + const { data: page } = + await axios.get(url); + + const batch: BulkUpdateItem[] = []; + + for (const transaction of page.data) { + try { + const item: BulkUpdateItem = { + id: transaction.id, + description_iv: null, + notes_iv: null, + }; + + if (transaction.description_iv) { + item.description = await decrypt( + transaction.description, + key, + transaction.description_iv, + ); + } + + if (transaction.notes_iv && transaction.notes) { + item.notes = await decrypt( + transaction.notes, + key, + transaction.notes_iv, + ); + } + + batch.push(item); + } catch { + // Skip transactions that fail to decrypt + } + } + + if (batch.length > 0) { + // Send in chunks of 50 + for (let i = 0; i < batch.length; i += 50) { + const chunk = batch.slice(i, i + 50); + await axios.patch('/api/transactions/bulk', { + transactions: chunk, + }); + } + } + + url = page.next_page_url; + } + + window.location.reload(); + } catch { + // Silent failure — migration will retry next session + hasRun.current = false; + } + } + + migrateTransactions(); + }, [isKeySet, hasEncryptedTransactions]); +} diff --git a/resources/js/layouts/app/app-sidebar-layout.tsx b/resources/js/layouts/app/app-sidebar-layout.tsx index 079e584b..5c8ec079 100644 --- a/resources/js/layouts/app/app-sidebar-layout.tsx +++ b/resources/js/layouts/app/app-sidebar-layout.tsx @@ -3,6 +3,7 @@ import { AppShell } from '@/components/app-shell'; import { AppSidebar } from '@/components/app-sidebar'; import { AppSidebarHeader } from '@/components/app-sidebar-header'; import { useDecryptAccountNames } from '@/hooks/use-decrypt-account-names'; +import { useDecryptTransactions } from '@/hooks/use-decrypt-transactions'; import { type BreadcrumbItem } from '@/types'; import { type PropsWithChildren } from 'react'; @@ -11,6 +12,7 @@ export default function AppSidebarLayout({ breadcrumbs = [], }: PropsWithChildren<{ breadcrumbs?: BreadcrumbItem[] }>) { useDecryptAccountNames(); + useDecryptTransactions(); return ( diff --git a/resources/js/services/transaction-sync.ts b/resources/js/services/transaction-sync.ts index c39565aa..a525831a 100644 --- a/resources/js/services/transaction-sync.ts +++ b/resources/js/services/transaction-sync.ts @@ -63,7 +63,7 @@ class TransactionSyncService { data: Omit, ): Promise { const response = await axios.post('/transactions', data); - const serverData = response.data.data; + const serverData = response.data.data || response.data; const label_ids = serverData.labels?.map((l: { id: string }) => l.id); // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -100,7 +100,7 @@ class TransactionSyncService { label_ids, }); - const serverData = response.data.data; + const serverData = response.data.data || response.data; const serverLabelIds = serverData.labels?.map( (l: { id: string }) => l.id, @@ -214,24 +214,24 @@ class TransactionSyncService { }); const keyString = getStoredKey(); - if (!keyString) { - console.warn('No encryption key found for duplicate check'); - return transactions.map(() => false); - } - - const key = await importKey(keyString); - const { decrypt } = await import('@/lib/crypto'); + const key = keyString ? await importKey(keyString) : null; const decryptedTransactions = await Promise.all( transactionsInRange.map(async (t) => { try { - const decryptedDescription = t.description_iv - ? await decrypt( - t.description, - key, - t.description_iv, - ) - : t.description; + let decryptedDescription: string; + if (t.description_iv && key) { + const { decrypt } = await import('@/lib/crypto'); + decryptedDescription = await decrypt( + t.description, + key, + t.description_iv, + ); + } else if (t.description_iv && !key) { + return null; + } else { + decryptedDescription = t.description; + } return { transaction_date: normalizeDate(t.transaction_date), amount: parseFloat(t.amount), diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 9c9ff912..6fe36e92 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -61,6 +61,7 @@ export interface SharedData { sidebarOpen: boolean; features: Features; hasEncryptedAccounts: boolean; + hasEncryptedTransactions: boolean; hasEncryptionSetup: boolean; locale: string; translations: Record; diff --git a/routes/api.php b/routes/api.php index 1fc93d81..ab2d5ee2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Api\AccountController; use App\Http\Controllers\Api\CashflowAnalyticsController; use App\Http\Controllers\Api\DashboardAnalyticsController; use App\Http\Controllers\Api\ImportDataController; +use App\Http\Controllers\Api\TransactionController; use App\Http\Controllers\EncryptionController; use App\Http\Controllers\Sync\TransactionSyncController; use Illuminate\Support\Facades\Route; @@ -22,6 +23,10 @@ Route::middleware(['web', 'auth'])->group(function () { Route::get('transactions', [TransactionSyncController::class, 'index']); }); + // Transactions + Route::get('transactions', [TransactionController::class, 'index'])->name('api.transactions.index'); + Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('api.transactions.bulk-update'); + // Accounts Route::get('accounts', [AccountController::class, 'index'])->name('api.accounts.index'); Route::put('accounts/{account}', [AccountController::class, 'update'])->name('api.accounts.update'); diff --git a/routes/web.php b/routes/web.php index 4c00cd30..1c7a07fd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -47,6 +47,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('onboarding', [OnboardingController::class, 'index'])->name('onboarding'); Route::post('onboarding/complete', [OnboardingController::class, 'complete'])->name('onboarding.complete'); }); + + // Accessible during onboarding for transaction import + Route::post('transactions', [TransactionController::class, 'store'])->name('transactions.store'); }); Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () { @@ -58,7 +61,6 @@ 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::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::delete('transactions/{transaction}', [TransactionController::class, 'destroy'])->name('transactions.destroy'); diff --git a/tests/Browser/OnboardingFlowTest.php b/tests/Browser/OnboardingFlowTest.php index 5ca84ed6..88efa3c6 100644 --- a/tests/Browser/OnboardingFlowTest.php +++ b/tests/Browser/OnboardingFlowTest.php @@ -88,21 +88,6 @@ it('navigates from welcome to account types', function () { ->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(); -}); - // ============================================================================= // Existing Account Flow Tests // ============================================================================= @@ -236,8 +221,10 @@ it('shows add another account form without first account restriction', function // Full End-to-End Flow Test // ============================================================================= -it('completes onboarding flow through account creation', function () { - // Create a bank for the account creation step +it('completes entire onboarding flow with account creation, transaction import, and ends on subscribe page', function () { + // Enable subscriptions so user ends on /subscribe after completing onboarding + config(['subscriptions.enabled' => true]); + Bank::factory()->create(['name' => 'Chase Bank']); $user = User::factory()->create([ @@ -259,59 +246,93 @@ it('completes onboarding flow through account creation', function () { // Step 2: Account Types $page->assertSee('Account Types') - ->assertSee('Checking') - ->assertSee('Savings') - ->assertSee('Credit Card') ->click('Create Your First Account') ->wait(1); - // Step 3: Create Account + // Step 3: Create Account (checking with EUR currency) $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")') + ->click('[role="option"]:has-text("EUR")') ->wait(1) ->click('Create Account') + ->wait(5); + + // Step 4: Import Transactions - open the import drawer + $page->assertSee('Import Your Transactions') + ->click('Import Transactions') ->wait(3); - // Step 4: Import Transactions step should appear - $page->assertSee('Import Your Transactions') + // The drawer auto-selects the only account and moves to Upload File step + // Upload the test CSV file + $csvPath = __DIR__.'/assets/test-transactions.csv'; + $page->attach('input[type="file"]', $csvPath) + ->wait(2) + ->click('Next') + ->wait(2); + + // Column Mapping step (auto-detected: Date, Description, Amount) + $page->assertSee('Map Columns') + ->click('Preview Transactions') + ->wait(3); + + // Preview step - import all 5 transactions from the CSV + $page->assertSee('Preview Transactions') + ->click('Import 5 Transactions') + ->wait(15); + + // After import completes, drawer closes and transitions to Category Types + $page->assertSee('Understanding Categories') + ->click('Continue') + ->wait(1); + + // Smart Rules + $page->assertSee('Smart Automation Rules') + ->click('Continue to Import') + ->wait(1); + + // More Accounts - verify account is listed, then finish + $page->assertSee('Great Progress!') + ->assertSee('My Checking Account') + ->click('Finish Setup') + ->wait(1); + + // Complete step + $page->assertSee("You're All Set!") + ->click('Go to Dashboard') + ->wait(5); + + // Since SUBSCRIPTIONS_ENABLED is true, user should end on /subscribe + $page->assertPathIs('/subscribe') ->assertNoJavascriptErrors(); - // 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'); - + // === Database Assertions === $user->refresh(); + + // User should be marked as onboarded expect($user->isOnboarded())->toBeTrue(); expect($user->onboarded_at)->not->toBeNull(); + + // User currency_code should match the first account's currency + expect($user->currency_code)->toBe('EUR'); + + // Account should exist with correct properties + $account = $user->accounts()->first(); + expect($account)->not->toBeNull(); + expect($account->type->value)->toBe('checking'); + expect($account->currency_code)->toBe('EUR'); + expect($account->name)->toBe('My Checking Account'); + + // Transactions should be imported in the correct account + $transactions = $user->transactions()->where('account_id', $account->id)->get(); + expect($transactions)->toHaveCount(5); + expect($transactions->pluck('currency_code')->unique()->first())->toBe('EUR'); }); diff --git a/tests/Feature/DecryptTransactionsTest.php b/tests/Feature/DecryptTransactionsTest.php new file mode 100644 index 00000000..a4045f8f --- /dev/null +++ b/tests/Feature/DecryptTransactionsTest.php @@ -0,0 +1,235 @@ +user = User::factory()->onboarded()->create(); + $this->actingAs($this->user); +}); + +test('encrypted transactions endpoint returns only encrypted transactions', function () { + $encrypted = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'description_iv' => 'some-iv', + ]); + Transaction::factory()->plaintext()->create([ + 'user_id' => $this->user->id, + ]); + + $response = $this->getJson('/api/transactions?encrypted=true'); + + $response->assertOk(); + $data = $response->json('data'); + expect($data)->toHaveCount(1); + expect($data[0]['id'])->toBe($encrypted->id); +}); + +test('plaintext filter returns only plaintext transactions', function () { + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'description_iv' => 'some-iv', + ]); + $plaintext = Transaction::factory()->plaintext()->create([ + 'user_id' => $this->user->id, + ]); + + $response = $this->getJson('/api/transactions?encrypted=false'); + + $response->assertOk(); + $data = $response->json('data'); + expect($data)->toHaveCount(1); + expect($data[0]['id'])->toBe($plaintext->id); +}); + +test('encrypted transactions endpoint paginates correctly', function () { + $account = Account::factory()->create(['user_id' => $this->user->id]); + $category = Category::factory()->create(['user_id' => $this->user->id]); + + Transaction::factory()->count(150)->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $category->id, + 'description_iv' => 'some-iv', + ]); + + $response = $this->getJson('/api/transactions?encrypted=true'); + + $response->assertOk(); + expect($response->json('data'))->toHaveCount(100); + expect($response->json('next_page_url'))->not->toBeNull(); + + $response2 = $this->getJson('/api/transactions?encrypted=true&page=2'); + expect($response2->json('data'))->toHaveCount(50); + expect($response2->json('next_page_url'))->toBeNull(); +}); + +test('encrypted transactions endpoint is scoped to authenticated user', function () { + $otherUser = User::factory()->create(); + + Transaction::factory()->create([ + 'user_id' => $otherUser->id, + 'description_iv' => 'some-iv', + ]); + $myTransaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'description_iv' => 'some-iv', + ]); + + $response = $this->getJson('/api/transactions?encrypted=true'); + + $response->assertOk(); + $data = $response->json('data'); + expect($data)->toHaveCount(1); + expect($data[0]['id'])->toBe($myTransaction->id); +}); + +test('bulk update clears IVs and sets plaintext values', function () { + $transaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'description' => 'encrypted-desc', + 'description_iv' => 'some-iv', + 'notes' => 'encrypted-notes', + 'notes_iv' => 'some-notes-iv', + ]); + + $response = $this->patchJson('/api/transactions/bulk', [ + 'transactions' => [ + [ + 'id' => $transaction->id, + 'description' => 'Decrypted description', + 'notes' => 'Decrypted notes', + 'description_iv' => null, + 'notes_iv' => null, + ], + ], + ]); + + $response->assertOk(); + + $transaction->refresh(); + expect($transaction->description)->toBe('Decrypted description'); + expect($transaction->notes)->toBe('Decrypted notes'); + expect($transaction->description_iv)->toBeNull(); + expect($transaction->notes_iv)->toBeNull(); +}); + +test('bulk update rejects other user transactions', function () { + $otherUser = User::factory()->create(); + $transaction = Transaction::factory()->create([ + 'user_id' => $otherUser->id, + 'description_iv' => 'some-iv', + ]); + + $response = $this->patchJson('/api/transactions/bulk', [ + 'transactions' => [ + [ + 'id' => $transaction->id, + 'description' => 'Stolen data', + 'description_iv' => null, + 'notes_iv' => null, + ], + ], + ]); + + $response->assertForbidden(); +}); + +test('bulk update validates max batch size', function () { + $account = Account::factory()->create(['user_id' => $this->user->id]); + $category = Category::factory()->create(['user_id' => $this->user->id]); + + $transactions = Transaction::factory()->count(51)->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $category->id, + 'description_iv' => 'some-iv', + ]); + + $payload = $transactions->map(fn ($t) => [ + 'id' => $t->id, + 'description' => 'decrypted', + 'description_iv' => null, + 'notes_iv' => null, + ])->toArray(); + + $response = $this->patchJson('/api/transactions/bulk', [ + 'transactions' => $payload, + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('transactions'); +}); + +test('bulk update validates required fields', function () { + $response = $this->patchJson('/api/transactions/bulk', [ + 'transactions' => [ + ['description' => 'missing id'], + ], + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('transactions.0.id'); +}); + +test('bulk update handles nullable notes correctly', function () { + $transaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'description' => 'encrypted-desc', + 'description_iv' => 'some-iv', + 'notes' => null, + 'notes_iv' => null, + ]); + + $response = $this->patchJson('/api/transactions/bulk', [ + 'transactions' => [ + [ + 'id' => $transaction->id, + 'description' => 'Decrypted description', + 'notes' => null, + 'description_iv' => null, + 'notes_iv' => null, + ], + ], + ]); + + $response->assertOk(); + + $transaction->refresh(); + expect($transaction->description)->toBe('Decrypted description'); + expect($transaction->notes)->toBeNull(); + expect($transaction->description_iv)->toBeNull(); + expect($transaction->notes_iv)->toBeNull(); +}); + +test('guests cannot access transactions API', function () { + auth()->logout(); + + $this->getJson('/api/transactions')->assertUnauthorized(); + $this->patchJson('/api/transactions/bulk', ['transactions' => []])->assertUnauthorized(); +}); + +test('bulk update does not fire model events', function () { + $transaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'description_iv' => 'some-iv', + ]); + + $originalUpdatedAt = $transaction->updated_at; + + $this->patchJson('/api/transactions/bulk', [ + 'transactions' => [ + [ + 'id' => $transaction->id, + 'description' => 'Decrypted', + 'description_iv' => null, + 'notes_iv' => null, + ], + ], + ])->assertOk(); + + $transaction->refresh(); + expect($transaction->updated_at->toDateTimeString())->toBe($originalUpdatedAt->toDateTimeString()); +});