Compare commits

..

1 Commits
main ... v0.2.2

Author SHA1 Message Date
Víctor Falcón dd51f4db7c chore: release v0.2.2 2026-05-22 08:45:28 +02:00
857 changed files with 10225 additions and 68132 deletions

View File

@ -1,436 +0,0 @@
---
name: ai-sdk-development
description: TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for other AI packages used directly.
license: MIT
metadata:
author: laravel
---
# Developing with the Laravel AI SDK
The Laravel AI SDK (`laravel/ai`) is the official AI package for Laravel, providing a unified API for agents, images, audio, transcription, embeddings, reranking, vector stores, and file management across multiple AI providers.
## Searching the Documentation
This package is new. Always search the documentation before implementing any feature. Never guess at APIs — the documentation is the single source of truth.
- Use broad, simple queries that match the documentation section headings below.
- Do not add package names to queries — package information is shared automatically. Use `test agent fake`, not `laravel ai test agent fake`.
- Run multiple queries at once — the most relevant results are returned first.
### Documentation Sections
Use these section headings as query terms for accurate results:
- Introduction, Installation, Configuration, Provider Support
- Agents: Prompting, Conversation Context, Structured Output, Attachments, Streaming, Broadcasting, Queueing, Tools, Provider Tools, Middleware, Anonymous Agents, Agent Configuration
- Images
- Audio (TTS)
- Transcription (STT)
- Embeddings: Querying Embeddings, Caching Embeddings
- Reranking
- Files
- Vector Stores: Adding Files to Stores
- Failover
- Testing: Agents, Images, Audio, Transcriptions, Embeddings, Reranking, Files, Vector Stores
- Events
## Decision Workflow
Determine the right entry point before writing code:
Text generation or chat? → Agent class with `Promptable` trait
Chat with conversation history? → Agent + `Conversational` interface (manual) or `RemembersConversations` trait (automatic)
Structured JSON output? → Agent + `HasStructuredOutput` interface
Image generation? → `Image::of()->generate()`
Audio synthesis? → `Audio::of()->generate()`
Transcription? → `Transcription::fromPath()->generate()`
Embeddings? → `Embeddings::for()->generate()`
Reranking? → `Reranking::of()->rerank()`
File storage? → `Document::fromPath()->put()`
Vector stores? → `Stores::create()`
## Basic Usage Examples
### Agents
```php
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
class SalesCoach implements Agent
{
use Promptable;
public function instructions(): string
{
return 'You are a sales coach.';
}
}
// Prompting
$response = (new SalesCoach)->prompt('Analyze this transcript...');
echo $response->text;
// Container resolution with dependency injection
$agent = SalesCoach::make(user: $user);
// Override provider, model, or timeout per-prompt
$response = (new SalesCoach)->prompt(
'Analyze this transcript...',
provider: Lab::Anthropic,
model: 'claude-haiku-4-5-20251001',
timeout: 120,
);
// Streaming (returns SSE response from a route)
return (new SalesCoach)->stream('Analyze this transcript...');
// Queueing
(new SalesCoach)->queue('Analyze this transcript...')
->then(fn ($response) => /* ... */);
// Anonymous agents
use function Laravel\Ai\{agent};
$response = agent(instructions: 'You are a helpful assistant.')->prompt('Hello');
```
### Conversation Context
Manual conversation history via the `Conversational` interface:
```php
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
class SalesCoach implements Agent, Conversational
{
use Promptable;
public function __construct(public User $user) {}
public function instructions(): string { return 'You are a sales coach.'; }
public function messages(): iterable
{
return History::where('user_id', $this->user->id)
->latest()->limit(50)->get()->reverse()
->map(fn ($m) => new Message($m->role, $m->content))
->all();
}
}
```
Automatic conversation persistence via the `RemembersConversations` trait:
```php
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Promptable;
class SalesCoach implements Agent, Conversational
{
use Promptable, RemembersConversations;
public function instructions(): string { return 'You are a sales coach.'; }
}
// Start a new conversation
$response = (new SalesCoach)->forUser($user)->prompt('Hello!');
$conversationId = $response->conversationId;
// Continue an existing conversation
$response = (new SalesCoach)->continue($conversationId, as: $user)->prompt('Tell me more.');
```
### Structured Output
```php
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
class Reviewer implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): string { return 'Review and score content.'; }
public function schema(JsonSchema $schema): array
{
return [
'feedback' => $schema->string()->required(),
'score' => $schema->integer()->min(1)->max(10)->required(),
];
}
}
$response = (new Reviewer)->prompt('Review this...');
echo $response['score']; // Access like an array
```
### Images
```php
use Laravel\Ai\Image;
$image = Image::of('A sunset over mountains')
->landscape()
->quality('high')
->generate();
$path = $image->store(); // Store to default disk
```
### Audio
```php
use Laravel\Ai\Audio;
$audio = Audio::of('Hello from Laravel.')
->female()
->instructions('Speak warmly')
->generate();
$path = $audio->store();
```
### Transcription
```php
use Laravel\Ai\Transcription;
$transcript = Transcription::fromStorage('audio.mp3')
->diarize()
->generate();
echo (string) $transcript;
```
### Embeddings
```php
use Laravel\Ai\Embeddings;
use Illuminate\Support\Str;
$response = Embeddings::for(['Text one', 'Text two'])
->dimensions(1536)
->cache()
->generate();
// Single string via Stringable
$embedding = Str::of('Napa Valley has great wine.')->toEmbeddings();
```
### Reranking
```php
use Laravel\Ai\Reranking;
$response = Reranking::of(['Django is Python.', 'Laravel is PHP.', 'React is JS.'])
->limit(5)
->rerank('PHP frameworks');
$response->first()->document; // "Laravel is PHP."
```
### Files and Vector Stores
```php
use Laravel\Ai\Files\Document;
use Laravel\Ai\Stores;
// Store a file with the provider
$file = Document::fromPath('/path/to/doc.pdf')->put();
// Create a vector store and add files
$store = Stores::create('Knowledge Base');
$store->add($file->id);
$store->add(Document::fromStorage('manual.pdf')); // Store + add in one step
```
## Agent Configuration
### PHP Attributes
```php
use Laravel\Ai\Attributes\{Provider, Model, MaxSteps, MaxTokens, Temperature, Timeout};
use Laravel\Ai\Enums\Lab;
#[Provider(Lab::Anthropic)]
#[Model('claude-haiku-4-5-20251001')]
#[MaxSteps(10)]
#[MaxTokens(4096)]
#[Temperature(0.7)]
#[Timeout(120)]
class MyAgent implements Agent
{
use Promptable;
// ...
}
```
The `#[UseCheapestModel]` and `#[UseSmartestModel]` attributes are also available for automatic model selection.
### Tools
Implement the `HasTools` interface and scaffold tools with `php artisan make:tool`:
```php
use Laravel\Ai\Contracts\HasTools;
class MyAgent implements Agent, HasTools
{
use Promptable;
public function tools(): iterable
{
return [new MyCustomTool];
}
}
```
### Provider Tools
```php
use Laravel\Ai\Providers\Tools\{WebSearch, WebFetch, FileSearch};
public function tools(): iterable
{
return [
(new WebSearch)->max(5)->allow(['laravel.com']),
new WebFetch,
new FileSearch(stores: ['store_id']),
];
}
```
### Conversation Memory
```php
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Conversational;
class ChatBot implements Agent, Conversational
{
use Promptable, RemembersConversations;
// ...
}
$response = (new ChatBot)->forUser($user)->prompt('Hello!');
$response = (new ChatBot)->continue($conversationId, as: $user)->prompt('More...');
```
### Failover
```php
$response = (new MyAgent)->prompt('Hello', provider: [Lab::OpenAI, Lab::Anthropic]);
```
## Testing and Faking
Each capability supports `fake()` with assertions:
```php
use App\Ai\Agents\SalesCoach;
use Laravel\Ai\{Image, Audio, Transcription, Embeddings, Reranking, Files, Stores};
// Agents
SalesCoach::fake(['Response 1', 'Response 2']);
SalesCoach::assertPrompted('query');
SalesCoach::assertNotPrompted('query');
SalesCoach::assertNeverPrompted();
SalesCoach::fake()->preventStrayPrompts();
// Images
Image::fake();
Image::assertGenerated(fn ($prompt) => $prompt->contains('sunset'));
Image::assertNothingGenerated();
// Audio
Audio::fake();
Audio::assertGenerated(fn ($prompt) => $prompt->contains('Hello'));
// Transcription
Transcription::fake(['Transcribed text.']);
Transcription::assertGenerated(fn ($prompt) => $prompt->isDiarized());
// Embeddings
Embeddings::fake();
Embeddings::assertGenerated(fn ($prompt) => $prompt->contains('Laravel'));
// Reranking
Reranking::fake();
Reranking::assertReranked(fn ($prompt) => $prompt->contains('PHP'));
// Files
Files::fake();
Files::assertStored(fn ($file) => $file->mimeType() === 'text/plain');
// Stores
Stores::fake();
Stores::assertCreated('Knowledge Base');
$store = Stores::get('id');
$store->assertAdded('file_id');
```
## Key Patterns
- Namespace: `Laravel\Ai\`
- Package: `composer require laravel/ai`
- Agent pattern: Implement the `Agent` interface and use the `Promptable` trait
- Optional interfaces: `HasTools`, `HasMiddleware`, `HasStructuredOutput`, `Conversational`
- Entry-point classes: `Image`, `Audio`, `Transcription`, `Embeddings`, `Reranking`, `Stores`
- Provider enum: `Laravel\Ai\Enums\Lab` (prefer over plain strings)
- Artisan commands: `php artisan make:agent`, `php artisan make:tool`
- Global helper: `agent()` for anonymous agents
## Common Pitfalls
### Wrong Namespace
The namespace is `Laravel\Ai`, not `Illuminate\Ai` or `Laravel\AI`.
```php
// Correct
use Laravel\Ai\Image;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
// Wrong — these do not exist
use Illuminate\Ai\Image;
use Laravel\AI\Agent;
```
### Unsupported Provider Capability
Calling a capability not supported by a provider throws a `LogicException`. Refer to the provider support table below.
## Provider Support
| Feature | Providers |
| ---------- | --------------------------------------------------------------- |
| Text | OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama |
| Images | OpenAI, Gemini, xAI |
| TTS | OpenAI, ElevenLabs |
| STT | OpenAI, ElevenLabs, Mistral |
| Embeddings | OpenAI, Gemini, Azure, Cohere, Mistral, Jina, VoyageAI |
| Reranking | Cohere, Jina |
| Files | OpenAI, Anthropic, Gemini |
Use the `Laravel\Ai\Enums\Lab` enum to reference providers in code instead of plain strings:
```php
use Laravel\Ai\Enums\Lab;
Lab::Anthropic;
Lab::OpenAI;
Lab::Gemini;
// ...
```

View File

@ -1,108 +0,0 @@
---
name: cashier-stripe-development
description: "Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues."
license: MIT
metadata:
author: laravel
---
# Cashier Stripe Development
## When to Apply
Activate this skill when:
- Installing or configuring Laravel Cashier Stripe
- Setting up subscriptions, trials, quantities, or plan swapping
- Handling webhooks or SCA/3DS payment failures
- Working with Stripe Checkout, invoices, or charges
- Testing billing scenarios with Stripe test cards or tokens
## Documentation
Use `search-docs` for detailed Cashier patterns and documentation covering subscriptions, webhooks, Stripe Checkout, invoices, payment methods, and testing.
For deeper guidance on specific topics, read the relevant reference file before implementing:
- `references/subscriptions.md` covers subscription creation, status checks, swapping, trials, quantities, and multiple products
- `references/webhooks.md` covers webhook setup, custom handlers, CSRF exclusion, and local development with the Stripe CLI
- `references/testing.md` covers Stripe test cards, payment method tokens, and feature test patterns
## Basic Usage
### Installation
```bash
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate
php artisan vendor:publish --tag="cashier-config"
```
### Environment Variables
```
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
CASHIER_CURRENCY=usd
CASHIER_CURRENCY_LOCALE=en_US
```
### Billable Model
<!-- Add Billable Trait -->
```php
use Laravel\Cashier\Billable;
class User extends Authenticatable
{
use Billable;
}
```
For a non-User model, register it in a service provider:
<!-- Custom Billable Model -->
```php
// In AppServiceProvider::boot()
Cashier::useCustomerModel(Team::class);
```
### Creating a Subscription
<!-- Create Subscription -->
```php
use Laravel\Cashier\Exceptions\IncompletePayment;
try {
$user->newSubscription('default', 'price_xxxx')->create($paymentMethodId);
} catch (IncompletePayment $e) {
return redirect()->route('cashier.payment', [$e->payment->id, 'redirect' => route('home')]);
}
```
Always wrap subscription creation in a try/catch for `IncompletePayment`. When a card requires 3DS authentication, Cashier throws this exception. The `cashier.payment` route is auto-registered and handles the confirmation flow.
## Verification
1. Run migrations and confirm `stripe_id`, `pm_type`, `pm_last_four`, and `trial_ends_at` columns exist on the billable model table
2. Test the webhook endpoint with `stripe listen --forward-to localhost/stripe/webhook` if you use the default path, or swap `stripe` for your configured `CASHIER_PATH`
3. Confirm `$user->subscribed('default')` returns the expected value for active and incomplete subscriptions
## Common Pitfalls
- The migration publish tag is `cashier-migrations`, not `cashier`. Running `migrate` before publishing results in missing columns and tables.
- `CASHIER_CURRENCY` must be set explicitly. It defaults to USD, which silently breaks non-US apps.
- The Stripe CLI generates its own webhook signing secret. It is different from the Dashboard endpoint secret. Using the wrong one causes signature verification failures.
- The webhook route must be excluded from CSRF verification using your configured `cashier.path`. If you change `CASHIER_PATH` from `stripe` to `billing`, exclude `billing/*`, not `stripe/*`.
- `canceled()` returns true as soon as `cancel()` is called, but the user still has access during the grace period. Use `ended()` to confirm access is fully revoked.
- `subscribed()` returns true during the grace period even though the subscription is canceled.
- `subscribed()` returns false for `incomplete` and `past_due` subscriptions by default.
- Prices cannot be swapped and quantity cannot be updated while a subscription has an incomplete payment.
- When extending `WebhookController`, call `Cashier::ignoreRoutes()` in a service provider and re-register both `cashier.payment` and `cashier.webhook` under the configured `cashier.path`.
- Use `Cashier::useCustomerModel()` in a service provider to set a custom billable model. There is no `CASHIER_MODEL` env var.
- `trial_ends_at` is a local database column synced via webhooks. It will be stale if webhooks are not configured in production.
- In MySQL, the `stripe_id` column must use `utf8_bin` collation to avoid case-sensitivity issues.
- `noProrate()` has no effect when combined with `swapAndInvoice()`. That method always prorates.
- Methods like `withPromotionCode()` require the Stripe API ID such as `promo_xxxx`, not the customer-facing code. Use `findPromotionCode()` to resolve a code to its ID.
- Always use `search-docs` for the latest Cashier documentation rather than relying on this skill alone.

View File

@ -1,108 +0,0 @@
# Subscriptions Reference
Use `search-docs` for authoritative documentation on subscriptions.
## Status Checks
| Method | Returns true when |
|---|---|
| `$user->subscribed('default')` | Active or on grace period |
| `->onTrial()` | Trial period active |
| `->onGracePeriod()` | Canceled, period not yet ended |
| `->canceled()` | `ends_at` is set, may still have access |
| `->ended()` | Canceled and grace period expired |
| `->incomplete()` | Awaiting SCA/3DS confirmation |
| `->pastDue()` | Payment overdue |
| `->recurring()` | Active and not on trial |
Check by product or price:
```php
$user->subscribedToProduct('prod_premium', 'default');
$user->subscribedToPrice('price_monthly', 'default');
```
## Swapping Plans
```php
$user->subscription('default')->swap('price_new');
$user->subscription('default')->noProrate()->swap('price_new');
$user->subscription('default')->swapAndInvoice('price_new');
$user->subscription('default')->skipTrial()->swap('price_new');
```
## Quantity
```php
$user->subscription('default')->incrementQuantity();
$user->subscription('default')->decrementQuantity();
$user->subscription('default')->updateQuantity(10);
$user->subscription('default')->noProrate()->updateQuantity(10);
```
## Trials
```php
$user->newSubscription('default', 'price_xxxx')
->trialDays(14)
->create($paymentMethodId);
$subscription->extendTrial(now()->addDays(7));
```
## Multiple Products on One Subscription
```php
$user->newSubscription('default', ['price_monthly', 'price_chat'])
->quantity(5, 'price_chat')
->create($paymentMethod);
$user->subscription('default')->addPrice('price_chat');
$user->subscription('default')->removePrice('price_chat');
$user->subscription('default')->swap(['price_pro', 'price_chat']);
```
## Multiple Subscriptions
```php
$user->newSubscription('swimming', 'price_swimming_monthly')->create($pm);
$user->newSubscription('gym', 'price_gym_monthly')->create($pm);
$user->subscription('swimming')->swap('price_swimming_yearly');
$user->subscription('gym')->cancel();
```
## Cancellation and Resumption
```php
$user->subscription('default')->cancel(); // At end of billing period
$user->subscription('default')->cancelNow(); // Immediately
$user->subscription('default')->resume(); // During grace period only
```
## Incomplete Payment Handling
```php
if ($user->hasIncompletePayment('default')) {
$paymentId = $user->subscription('default')->latestPayment()->id;
return redirect()->route('cashier.payment', $paymentId);
}
```
Opt out of default deactivation behavior:
```php
Cashier::keepPastDueSubscriptionsActive();
Cashier::keepIncompleteSubscriptionsActive();
```
## Metered / Usage-Based Billing
```php
$user->newSubscription('default')
->meteredPrice('price_metered')
->create($paymentMethodId);
$user->reportMeterEvent('emails-sent');
$user->reportMeterEvent('emails-sent', quantity: 15);
```

View File

@ -1,52 +0,0 @@
# Testing Reference
Use `search-docs` for authoritative documentation on testing Cashier integrations.
## Test Cards and Tokens
Use card numbers for browser-based flows (Stripe.js / Checkout). Use `pm_card_*` tokens directly in feature tests that call the Stripe API.
| Card Number | Token | Behavior |
|---|---|---|
| `4242 4242 4242 4242` | `pm_card_visa` | Succeeds immediately |
| `4000 0025 0000 3155` | `pm_card_threeDSecure2Required` | Requires SCA/3DS |
| `4000 0027 6000 3184` | `pm_card_authenticationRequired` | Requires authentication |
| `4000 0000 0000 9995` | `pm_card_chargeDeclinedInsufficientFunds` | Declined, insufficient funds |
| `4000 0000 0000 0002` | `pm_card_chargeDeclined` | Declined |
Use expiry `12/34`, any CVC, any ZIP for card number inputs.
## Feature Test Example
Feature tests that hit the real Stripe test API use `pm_card_*` tokens:
```php
public function test_user_can_subscribe(): void
{
$user = User::factory()->create();
$user->newSubscription('default', 'price_xxxx')
->create('pm_card_visa');
$this->assertTrue($user->subscribed('default'));
}
public function test_incomplete_payment_is_handled(): void
{
$user = User::factory()->create();
try {
$user->newSubscription('default', 'price_xxxx')
->create('pm_card_threeDSecure2Required');
} catch (\Laravel\Cashier\Exceptions\IncompletePayment $e) {
$this->assertTrue($user->subscription('default')->incomplete());
}
}
```
## Setup Notes
- Use Stripe test mode keys (`sk_test_...`, `pk_test_...`) in your test environment
- Cashier does not ship a global `fake()` helper. Tests hit the real Stripe test API by default.
- Refer to `tests/Feature/` in the Cashier package itself for integration test patterns covering subscription creation, payment methods, and webhook handling
- Use `search-docs` for current guidance on mocking Stripe HTTP calls or using Stripe's test clock feature for time-sensitive scenarios

View File

@ -1,132 +0,0 @@
# Webhooks Reference
Use `search-docs` for authoritative documentation on webhooks.
## Auto-Registered Routes
Cashier registers two routes automatically under the `cashier.path` prefix (`config('cashier.path')`, default `stripe`):
- `POST /{cashier.path}/webhook` named `cashier.webhook`
- `GET /{cashier.path}/payment/{id}` named `cashier.payment`
With the default config these are `/stripe/webhook` and `/stripe/payment/{id}`. If you set `CASHIER_PATH=billing`, they become `/billing/webhook` and `/billing/payment/{id}`.
## CSRF Exclusion
Use the same path prefix you configured for Cashier here. If `CASHIER_PATH=billing`, exclude `billing/*` instead of `stripe/*`.
**Laravel 11+ (`bootstrap/app.php`, default path example):**
```php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['stripe/*']);
})
```
**Laravel 10 (`app/Http/Middleware/VerifyCsrfToken.php`, default path example):**
```php
protected $except = [
'stripe/*',
];
```
## Local Development with Stripe CLI
If you changed `cashier.path`, forward Stripe CLI events to that URL instead of `/stripe/webhook`.
```bash
stripe login
stripe listen --forward-to your-app.test/stripe/webhook
stripe trigger invoice.payment_succeeded
```
The CLI outputs a `whsec_...` signing secret specific to that session. Set it as `STRIPE_WEBHOOK_SECRET` locally. It is not the same as the Dashboard endpoint secret.
## Registering Events in the Stripe Dashboard
Use the Artisan command to create the endpoint automatically with all required events:
```bash
php artisan cashier:webhook
```
Cashier's `cashier:webhook` command registers these events by default:
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `customer.updated` / `customer.deleted`
- `invoice.payment_action_required`
- `invoice.payment_succeeded`
- `payment_method.automatically_updated`
Cashier's `WebhookController` has built-in handlers for all of the above except `invoice.payment_succeeded`. For renewal hooks, prefer `WebhookReceived` / `WebhookHandled` listeners unless you intentionally add your own controller method.
## Custom Handlers: Extending WebhookController
Method name pattern: `handle` + StudlyCase of event type with dots replaced by underscores.
`customer.subscription.created` becomes `handleCustomerSubscriptionCreated`.
```php
use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;
class StripeWebhookController extends CashierController
{
public function handleCustomerSubscriptionCreated(array $payload)
{
$response = parent::handleCustomerSubscriptionCreated($payload);
// your logic after Cashier syncs the subscription
return $response;
}
}
```
If you add a method for an event Cashier does not handle internally, such as `invoice.payment_succeeded`, do not call `parent::handle...()` unless the base controller actually defines that method.
In a service provider, disable auto-registration and re-register both Cashier routes so the incomplete-payment flow and `cashier:webhook` command keep working:
```php
Cashier::ignoreRoutes();
```
```php
// routes/web.php
use App\Http\Controllers\StripeWebhookController;
use Illuminate\Support\Facades\Route;
use Laravel\Cashier\Http\Controllers\PaymentController;
Route::prefix(config('cashier.path'))
->name('cashier.')
->group(function () {
Route::get('payment/{id}', [PaymentController::class, 'show'])->name('payment');
Route::post('webhook', [StripeWebhookController::class, 'handleWebhook'])->name('webhook');
});
```
Keep the `cashier.webhook` route name unless you plan to pass `--url` explicitly to `php artisan cashier:webhook`.
## Custom Handlers: Listening to Events
The simpler option when you do not need to replace Cashier's internal logic, or when you want to react to events such as `invoice.payment_succeeded` that Cashier does not process itself:
```php
use Laravel\Cashier\Events\WebhookReceived;
use Laravel\Cashier\Events\WebhookHandled;
// WebhookReceived fires for every event before Cashier processes it
// WebhookHandled fires after Cashier processes it
Event::listen(WebhookReceived::class, function (WebhookReceived $event) {
if ($event->payload['type'] === 'invoice.payment_succeeded') {
// handle renewal
}
});
```
## Signature Verification
`VerifyWebhookSignature` middleware is applied automatically when `cashier.webhook.secret` is set. No extra wiring is needed.

View File

@ -1,5 +1,5 @@
---
name: fortify-development
name: developing-with-fortify
description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
---

View File

@ -15,7 +15,7 @@ Activate this skill when:
- Creating or modifying React page components for Inertia
- Working with forms in React (using `<Form>` or `useForm`)
- Implementing client-side navigation with `<Link>` or `router`
- Using v2 features: deferred props, prefetching, WhenVisible, InfiniteScroll, once props, flash data, or polling
- Using v2 features: deferred props, prefetching, or polling
- Building React-specific features with the Inertia protocol
## Documentation
@ -295,21 +295,14 @@ export default function UsersIndex({ users }) {
### Polling
Automatically refresh data at intervals:
Use the `usePoll` hook to automatically refresh data at intervals. It handles cleanup on unmount and throttles polling when the tab is inactive.
<!-- Polling Example -->
<!-- Basic Polling -->
```react
import { router } from '@inertiajs/react'
import { useEffect } from 'react'
import { usePoll } from '@inertiajs/react'
export default function Dashboard({ stats }) {
useEffect(() => {
const interval = setInterval(() => {
router.reload({ only: ['stats'] })
}, 5000) // Poll every 5 seconds
return () => clearInterval(interval)
}, [])
usePoll(5000)
return (
<div>
@ -320,37 +313,64 @@ export default function Dashboard({ stats }) {
}
```
### WhenVisible
Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
<!-- WhenVisible Example -->
<!-- Polling With Request Options and Manual Control -->
```react
import { WhenVisible } from '@inertiajs/react'
import { usePoll } from '@inertiajs/react'
export default function Dashboard({ stats }) {
const { start, stop } = usePoll(5000, {
only: ['stats'],
onStart() {
console.log('Polling request started')
},
onFinish() {
console.log('Polling request finished')
},
}, {
autoStart: false,
keepAlive: true,
})
return (
<div>
<h1>Dashboard</h1>
{/* stats prop is loaded only when this section scrolls into view */}
<WhenVisible data="stats" buffer={200} fallback={<div className="animate-pulse">Loading stats...</div>}>
{({ fetching }) => (
<div>
<p>Total Users: {stats.total_users}</p>
<p>Revenue: {stats.revenue}</p>
{fetching && <span>Refreshing...</span>}
</div>
)}
</WhenVisible>
<div>Active Users: {stats.activeUsers}</div>
<button onClick={start}>Start Polling</button>
<button onClick={stop}>Stop Polling</button>
</div>
)
}
```
## Server-Side Patterns
- `autoStart` (default `true`) — set to `false` to start polling manually via the returned `start()` function
- `keepAlive` (default `false`) — set to `true` to prevent throttling when the browser tab is inactive
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.
### WhenVisible (Infinite Scroll)
Load more data when user scrolls to a specific element:
<!-- Infinite Scroll with WhenVisible -->
```react
import { WhenVisible } from '@inertiajs/react'
export default function UsersList({ users }) {
return (
<div>
{users.data.map(user => (
<div key={user.id}>{user.name}</div>
))}
{users.next_page_url && (
<WhenVisible
data="users"
params={{ page: users.current_page + 1 }}
fallback={<div>Loading more...</div>}
/>
)}
</div>
)
}
```
## Common Pitfalls

View File

@ -1,6 +1,6 @@
---
name: pennant-development
description: "Use when working with Laravel Pennant the official Laravel feature flag package. Trigger whenever the query mentions Pennant by name or involves feature flags or feature toggles in a Laravel project. Tasks include defining feature flags checking whether features are active creating class based features in `app/Features` using Blade `@feature` directives scoping flags to users or teams building custom Pennant storage drivers protecting routes with feature flags testing feature flags with Pest or PHPUnit and implementing A B testing or gradual rollouts with feature flags. Do not trigger for generic Laravel configuration authorization policies authentication or non Pennant feature management systems."
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."
license: MIT
metadata:
author: laravel

View File

@ -1,6 +1,6 @@
---
name: pest-testing
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
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."
license: MIT
metadata:
author: laravel
@ -8,6 +8,16 @@ metadata:
# 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.

View File

@ -1,54 +0,0 @@
---
name: querying-the-database
description: "Query the local or production database from the CLI via the `agent:db` artisan command. Activates when the user asks to inspect, count, look up, or run a query against the database; asks 'how many X', 'what's in prod', 'check the prod DB', 'query the database'; or mentions production data, the prod database, or running SQL."
metadata:
author: whisper-money
---
# Querying the Database
## When to Apply
Activate this skill whenever you need to read data from the database to answer a
question — especially anything about **production** data. Prefer this command over
the `tinker`, `database-query`, or `database-schema` Boost tools when the user asks
about prod, since those default to the local connection.
## The `agent:db` command
Runs a query (`SELECT`, `SHOW`, `DESCRIBE`, `DELETE`, etc.) and prints the result.
```bash
php artisan agent:db "<query>"
```
### Options
- `--format=json` (default) — pretty-printed JSON, best for parsing the result yourself.
- `--format=table` — classic console table, best when showing the result to the user.
- `--prod` — run against the **production** database (the `prod` connection backed by
`PROD_DB_URL`). Without this flag the query runs against the local DB.
### Examples
```bash
# Local, JSON (default)
php artisan agent:db "select id, email from users limit 5"
# Local, human-readable table
php artisan agent:db --format=table "select count(*) as total from transactions"
# Production
php artisan agent:db --prod "select count(*) from users"
php artisan agent:db --prod --format=table "select status, count(*) from subscriptions group by status"
```
## Guidelines
- **Be careful with `--prod`**: this is live customer data. Only run prod queries the
user explicitly asked for, keep them scoped (add `LIMIT`, filter by id), and never
dump large or sensitive datasets unprompted. This app is privacy-first.
- Use `--format=json` when you need to read the values to continue working; use
`--format=table` when presenting results back to the user.
- Inspect schema first with `database-schema` (local) when you're unsure of column
names before writing a query.

View File

@ -1,6 +1,6 @@
---
name: tailwindcss-development
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
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."
license: MIT
metadata:
author: laravel
@ -8,6 +8,16 @@ metadata:
# 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.

View File

@ -8,6 +8,13 @@ metadata:
# 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.

View File

@ -1 +0,0 @@
../.agents/skills

View File

@ -1,5 +1,5 @@
---
name: fortify-development
name: developing-with-fortify
description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
---

View File

@ -0,0 +1,381 @@
---
name: inertia-react-development
description: "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."
license: MIT
metadata:
author: laravel
---
# Inertia React Development
## When to Apply
Activate this skill when:
- Creating or modifying React page components for Inertia
- Working with forms in React (using `<Form>` or `useForm`)
- Implementing client-side navigation with `<Link>` 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
<!-- Basic React Page Component -->
```react
export default function UsersIndex({ users }) {
return (
<div>
<h1>Users</h1>
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
</div>
)
}
```
## Client-Side Navigation
### Basic Link Component
Use `<Link>` for client-side navigation instead of traditional `<a>` tags:
<!-- Inertia React Navigation -->
```react
import { Link, router } from '@inertiajs/react'
<Link href="/">Home</Link>
<Link href="/users">Users</Link>
<Link href={`/users/${user.id}`}>View User</Link>
```
### Link with Method
<!-- Link with POST Method -->
```react
import { Link } from '@inertiajs/react'
<Link href="/logout" method="post" as="button">
Logout
</Link>
```
### Prefetching
Prefetch pages to improve perceived performance:
<!-- Prefetch on Hover -->
```react
import { Link } from '@inertiajs/react'
<Link href="/users" prefetch>
Users
</Link>
```
### Programmatic Navigation
<!-- Router Visit -->
```react
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 `<Form>` component:
<!-- Form Component Example -->
```react
import { Form } from '@inertiajs/react'
export default function CreateUser() {
return (
<Form action="/users" method="post">
{({ errors, processing, wasSuccessful }) => (
<>
<input type="text" name="name" />
{errors.name && <div>{errors.name}</div>}
<input type="email" name="email" />
{errors.email && <div>{errors.email}</div>}
<button type="submit" disabled={processing}>
{processing ? 'Creating...' : 'Create User'}
</button>
{wasSuccessful && <div>User created!</div>}
</>
)}
</Form>
)
}
```
### Form Component With All Props
<!-- Form Component Full Example -->
```react
import { Form } from '@inertiajs/react'
<Form action="/users" method="post">
{({
errors,
hasErrors,
processing,
progress,
wasSuccessful,
recentlySuccessful,
clearErrors,
resetAndClearErrors,
defaults,
isDirty,
reset,
submit
}) => (
<>
<input type="text" name="name" defaultValue={defaults.name} />
{errors.name && <div>{errors.name}</div>}
<button type="submit" disabled={processing}>
{processing ? 'Saving...' : 'Save'}
</button>
{progress && (
<progress value={progress.percentage} max="100">
{progress.percentage}%
</progress>
)}
{wasSuccessful && <div>Saved!</div>}
</>
)}
</Form>
```
### Form Component Reset Props
The `<Form>` 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.
<!-- Form with Reset Props -->
```react
import { Form } from '@inertiajs/react'
<Form
action="/users"
method="post"
resetOnSuccess
setDefaultsOnSuccess
>
{({ errors, processing, wasSuccessful }) => (
<>
<input type="text" name="name" />
{errors.name && <div>{errors.name}</div>}
<button type="submit" disabled={processing}>
Submit
</button>
</>
)}
</Form>
```
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:
<!-- useForm Hook Example -->
```react
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 (
<form onSubmit={submit}>
<input
type="text"
value={data.name}
onChange={e => setData('name', e.target.value)}
/>
{errors.name && <div>{errors.name}</div>}
<input
type="email"
value={data.email}
onChange={e => setData('email', e.target.value)}
/>
{errors.email && <div>{errors.email}</div>}
<input
type="password"
value={data.password}
onChange={e => setData('password', e.target.value)}
/>
{errors.password && <div>{errors.password}</div>}
<button type="submit" disabled={processing}>
Create User
</button>
</form>
)
}
```
## Inertia v2 Features
### Deferred Props
Use deferred props to load data after initial page render:
<!-- Deferred Props with Empty State -->
```react
export default function UsersIndex({ users }) {
// users will be undefined initially, then populated
return (
<div>
<h1>Users</h1>
{!users ? (
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
) : (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
)
}
```
### Polling
Use the `usePoll` hook to automatically refresh data at intervals. It handles cleanup on unmount and throttles polling when the tab is inactive.
<!-- Basic Polling -->
```react
import { usePoll } from '@inertiajs/react'
export default function Dashboard({ stats }) {
usePoll(5000)
return (
<div>
<h1>Dashboard</h1>
<div>Active Users: {stats.activeUsers}</div>
</div>
)
}
```
<!-- Polling With Request Options and Manual Control -->
```react
import { usePoll } from '@inertiajs/react'
export default function Dashboard({ stats }) {
const { start, stop } = usePoll(5000, {
only: ['stats'],
onStart() {
console.log('Polling request started')
},
onFinish() {
console.log('Polling request finished')
},
}, {
autoStart: false,
keepAlive: true,
})
return (
<div>
<h1>Dashboard</h1>
<div>Active Users: {stats.activeUsers}</div>
<button onClick={start}>Start Polling</button>
<button onClick={stop}>Stop Polling</button>
</div>
)
}
```
- `autoStart` (default `true`) — set to `false` to start polling manually via the returned `start()` function
- `keepAlive` (default `false`) — set to `true` to prevent throttling when the browser tab is inactive
### WhenVisible (Infinite Scroll)
Load more data when user scrolls to a specific element:
<!-- Infinite Scroll with WhenVisible -->
```react
import { WhenVisible } from '@inertiajs/react'
export default function UsersList({ users }) {
return (
<div>
{users.data.map(user => (
<div key={user.id}>{user.name}</div>
))}
{users.next_page_url && (
<WhenVisible
data="users"
params={{ page: users.current_page + 1 }}
fallback={<div>Loading more...</div>}
/>
)}
</div>
)
}
```
## Common Pitfalls
- Using traditional `<a>` links instead of Inertia's `<Link>` 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 `<form>` without preventing default submission (use `<Form>` component or `e.preventDefault()`)
- Forgetting to check if `<Form>` component is available in your Inertia version

View File

@ -0,0 +1,77 @@
---
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."
license: MIT
metadata:
author: laravel
---
# 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
<!-- Defining Features -->
```php
use Laravel\Pennant\Feature;
Feature::define('new-dashboard', function (User $user) {
return $user->isAdmin();
});
```
### Checking Features
<!-- Checking Features -->
```php
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
<!-- Blade Directive -->
```blade
@feature('new-dashboard')
<x-new-dashboard />
@else
<x-old-dashboard />
@endfeature
```
### Activating / Deactivating
<!-- Activating Features -->
```php
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

View File

@ -0,0 +1,167 @@
---
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."
license: MIT
metadata:
author: laravel
---
# 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
<!-- Basic Pest Test Example -->
```php
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()`:
<!-- Pest Response Assertion -->
```php
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.):
<!-- Pest Dataset Example -->
```php
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.
<!-- Pest Browser Test Example -->
```php
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:
<!-- Pest Smoke Testing Example -->
```php
$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):
<!-- Architecture Test Example -->
```php
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

View File

@ -0,0 +1,129 @@
---
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."
license: MIT
metadata:
author: laravel
---
# 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:
<!-- CSS-First Config -->
```css
@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:
<!-- v4 Import Syntax -->
```diff
- @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:
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## 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:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## 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

View File

@ -0,0 +1,87 @@
---
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."
license: MIT
metadata:
author: laravel
---
# 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:
```bash
php artisan wayfinder:generate --no-interaction
```
For form helpers, use `--with-form` flag:
```bash
php artisan wayfinder:generate --with-form --no-interaction
```
### Import Patterns
<!-- Controller Action Imports -->
```typescript
// 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
<!-- Wayfinder Methods -->
```typescript
// 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 `<Form>` component:
<!-- Wayfinder Form (React) -->
```typescript
<Form {...store.form()}><input name="title" /></Form>
```
## 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

View File

@ -8,12 +8,6 @@ APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
# Passport OAuth signing keys for the MCP OAuth server. Leave blank to use the
# keys generated by `php artisan passport:keys` under storage/. Set both (PEM
# contents) to share one key pair across multiple app instances.
PASSPORT_PRIVATE_KEY=
PASSPORT_PUBLIC_KEY=
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
@ -63,7 +57,7 @@ MAIL_DRIP_FROM_ADDRESS="hi@whisper.money"
MAIL_DRIP_FROM_NAME="Álvaro and Víctor"
ADMIN_EMAIL=
# Resend contact sync (optional, not used for sending email)
# Resend Email Service (set MAIL_MAILER=resend to use in production)
RESEND_API_KEY=
RESEND_LEADS_SEGMENT_ID=
@ -73,11 +67,9 @@ DRIP_EMAILS_ENABLED=true
# Email Verification (disable in local to auto-verify new users)
EMAIL_VERIFICATION_ENABLED=false
# Amazon SES Email Service (set MAIL_MAILER=ses to use in production)
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_SESSION_TOKEN=
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
@ -88,8 +80,7 @@ VITE_APP_NAME="${APP_NAME}"
# PostHog Analytics
VITE_POSTHOG_ENABLED=false
VITE_POSTHOG_API_KEY=
VITE_POSTHOG_HOST=https://t.whisper.money
VITE_POSTHOG_UI_HOST=https://eu.posthog.com
VITE_POSTHOG_HOST=https://eu.i.posthog.com
# Stripe Configuration
STRIPE_KEY=
@ -127,31 +118,3 @@ ENABLEBANKING_REDIRECT_URL="${APP_URL}/open-banking/callback"
DEMO_EMAIL=demo@whisper.money
DEMO_PASSWORD=demo
DEMO_ENCRYPTION_KEY=demo
# AI rule suggestions (powered by laravel/ai + Gemini)
GEMINI_API_KEY=
GEMINI_BASE_URL=
# Model is overridable without a deploy; defaults to a Flash-tier model.
# GEMINI_BASE_URL is optional — leave empty to use Google AI Studio
# (https://generativelanguage.googleapis.com/v1beta). Only set it for a
# proxy or Vertex AI gateway.
AI_SUGGESTIONS_MODEL=gemini-flash-latest
AI_SUGGESTIONS_MIN_GROUP_COUNT=2
AI_SUGGESTIONS_MAX_GROUPS=40
AI_SUGGESTIONS_CONFIDENCE_FLOOR=0.3
AI_SUGGESTIONS_AUTO_SELECT=0.6
AI_SUGGESTIONS_OVERBROAD_FRACTION=0.4
AI_SUGGESTIONS_MIN_TRANSACTIONS=50
AI_SUGGESTIONS_THROTTLE_DAYS=30
AI_SUGGESTIONS_CONSENT_VERSION=1
# AI Suggestions cohort report (stats:ai-cohort-report)
AI_SUGGESTIONS_REPORT_WEEKS=16
# Comma-separated staff/test emails to exclude from cohort metrics (e.g. the
# account that plants the release-anchor consent on deploy).
AI_SUGGESTIONS_REPORT_EXCLUDED_EMAILS=
# Discord webhooks
DISCORD_WEBHOOK_URL=
# Optional dedicated channel for the AI cohort report; falls back to DISCORD_WEBHOOK_URL.
DISCORD_AI_COHORT_WEBHOOK_URL=

View File

@ -14,15 +14,6 @@ APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
# Trusted Proxies
# Comma-separated proxy IPs/CIDRs Laravel trusts for X-Forwarded-* headers (no spaces).
# When unset the app trusts all proxies (*), which keeps small private self-hosted
# setups working behind any proxy out of the box. On a publicly exposed deployment set
# this to the actual proxy range: otherwise clients can spoof X-Forwarded-For to forge
# their IP (defeating per-IP rate limiting and poisoning logs). The private ranges below
# cover the Docker network Coolify/Traefik connects from.
TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
# Logging
LOG_CHANNEL=stack
LOG_STACK=single,sentry_logs

View File

@ -1,108 +0,0 @@
---
name: cashier-stripe-development
description: "Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues."
license: MIT
metadata:
author: laravel
---
# Cashier Stripe Development
## When to Apply
Activate this skill when:
- Installing or configuring Laravel Cashier Stripe
- Setting up subscriptions, trials, quantities, or plan swapping
- Handling webhooks or SCA/3DS payment failures
- Working with Stripe Checkout, invoices, or charges
- Testing billing scenarios with Stripe test cards or tokens
## Documentation
Use `search-docs` for detailed Cashier patterns and documentation covering subscriptions, webhooks, Stripe Checkout, invoices, payment methods, and testing.
For deeper guidance on specific topics, read the relevant reference file before implementing:
- `references/subscriptions.md` covers subscription creation, status checks, swapping, trials, quantities, and multiple products
- `references/webhooks.md` covers webhook setup, custom handlers, CSRF exclusion, and local development with the Stripe CLI
- `references/testing.md` covers Stripe test cards, payment method tokens, and feature test patterns
## Basic Usage
### Installation
```bash
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate
php artisan vendor:publish --tag="cashier-config"
```
### Environment Variables
```
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
CASHIER_CURRENCY=usd
CASHIER_CURRENCY_LOCALE=en_US
```
### Billable Model
<!-- Add Billable Trait -->
```php
use Laravel\Cashier\Billable;
class User extends Authenticatable
{
use Billable;
}
```
For a non-User model, register it in a service provider:
<!-- Custom Billable Model -->
```php
// In AppServiceProvider::boot()
Cashier::useCustomerModel(Team::class);
```
### Creating a Subscription
<!-- Create Subscription -->
```php
use Laravel\Cashier\Exceptions\IncompletePayment;
try {
$user->newSubscription('default', 'price_xxxx')->create($paymentMethodId);
} catch (IncompletePayment $e) {
return redirect()->route('cashier.payment', [$e->payment->id, 'redirect' => route('home')]);
}
```
Always wrap subscription creation in a try/catch for `IncompletePayment`. When a card requires 3DS authentication, Cashier throws this exception. The `cashier.payment` route is auto-registered and handles the confirmation flow.
## Verification
1. Run migrations and confirm `stripe_id`, `pm_type`, `pm_last_four`, and `trial_ends_at` columns exist on the billable model table
2. Test the webhook endpoint with `stripe listen --forward-to localhost/stripe/webhook` if you use the default path, or swap `stripe` for your configured `CASHIER_PATH`
3. Confirm `$user->subscribed('default')` returns the expected value for active and incomplete subscriptions
## Common Pitfalls
- The migration publish tag is `cashier-migrations`, not `cashier`. Running `migrate` before publishing results in missing columns and tables.
- `CASHIER_CURRENCY` must be set explicitly. It defaults to USD, which silently breaks non-US apps.
- The Stripe CLI generates its own webhook signing secret. It is different from the Dashboard endpoint secret. Using the wrong one causes signature verification failures.
- The webhook route must be excluded from CSRF verification using your configured `cashier.path`. If you change `CASHIER_PATH` from `stripe` to `billing`, exclude `billing/*`, not `stripe/*`.
- `canceled()` returns true as soon as `cancel()` is called, but the user still has access during the grace period. Use `ended()` to confirm access is fully revoked.
- `subscribed()` returns true during the grace period even though the subscription is canceled.
- `subscribed()` returns false for `incomplete` and `past_due` subscriptions by default.
- Prices cannot be swapped and quantity cannot be updated while a subscription has an incomplete payment.
- When extending `WebhookController`, call `Cashier::ignoreRoutes()` in a service provider and re-register both `cashier.payment` and `cashier.webhook` under the configured `cashier.path`.
- Use `Cashier::useCustomerModel()` in a service provider to set a custom billable model. There is no `CASHIER_MODEL` env var.
- `trial_ends_at` is a local database column synced via webhooks. It will be stale if webhooks are not configured in production.
- In MySQL, the `stripe_id` column must use `utf8_bin` collation to avoid case-sensitivity issues.
- `noProrate()` has no effect when combined with `swapAndInvoice()`. That method always prorates.
- Methods like `withPromotionCode()` require the Stripe API ID such as `promo_xxxx`, not the customer-facing code. Use `findPromotionCode()` to resolve a code to its ID.
- Always use `search-docs` for the latest Cashier documentation rather than relying on this skill alone.

View File

@ -1,108 +0,0 @@
# Subscriptions Reference
Use `search-docs` for authoritative documentation on subscriptions.
## Status Checks
| Method | Returns true when |
|---|---|
| `$user->subscribed('default')` | Active or on grace period |
| `->onTrial()` | Trial period active |
| `->onGracePeriod()` | Canceled, period not yet ended |
| `->canceled()` | `ends_at` is set, may still have access |
| `->ended()` | Canceled and grace period expired |
| `->incomplete()` | Awaiting SCA/3DS confirmation |
| `->pastDue()` | Payment overdue |
| `->recurring()` | Active and not on trial |
Check by product or price:
```php
$user->subscribedToProduct('prod_premium', 'default');
$user->subscribedToPrice('price_monthly', 'default');
```
## Swapping Plans
```php
$user->subscription('default')->swap('price_new');
$user->subscription('default')->noProrate()->swap('price_new');
$user->subscription('default')->swapAndInvoice('price_new');
$user->subscription('default')->skipTrial()->swap('price_new');
```
## Quantity
```php
$user->subscription('default')->incrementQuantity();
$user->subscription('default')->decrementQuantity();
$user->subscription('default')->updateQuantity(10);
$user->subscription('default')->noProrate()->updateQuantity(10);
```
## Trials
```php
$user->newSubscription('default', 'price_xxxx')
->trialDays(14)
->create($paymentMethodId);
$subscription->extendTrial(now()->addDays(7));
```
## Multiple Products on One Subscription
```php
$user->newSubscription('default', ['price_monthly', 'price_chat'])
->quantity(5, 'price_chat')
->create($paymentMethod);
$user->subscription('default')->addPrice('price_chat');
$user->subscription('default')->removePrice('price_chat');
$user->subscription('default')->swap(['price_pro', 'price_chat']);
```
## Multiple Subscriptions
```php
$user->newSubscription('swimming', 'price_swimming_monthly')->create($pm);
$user->newSubscription('gym', 'price_gym_monthly')->create($pm);
$user->subscription('swimming')->swap('price_swimming_yearly');
$user->subscription('gym')->cancel();
```
## Cancellation and Resumption
```php
$user->subscription('default')->cancel(); // At end of billing period
$user->subscription('default')->cancelNow(); // Immediately
$user->subscription('default')->resume(); // During grace period only
```
## Incomplete Payment Handling
```php
if ($user->hasIncompletePayment('default')) {
$paymentId = $user->subscription('default')->latestPayment()->id;
return redirect()->route('cashier.payment', $paymentId);
}
```
Opt out of default deactivation behavior:
```php
Cashier::keepPastDueSubscriptionsActive();
Cashier::keepIncompleteSubscriptionsActive();
```
## Metered / Usage-Based Billing
```php
$user->newSubscription('default')
->meteredPrice('price_metered')
->create($paymentMethodId);
$user->reportMeterEvent('emails-sent');
$user->reportMeterEvent('emails-sent', quantity: 15);
```

View File

@ -1,52 +0,0 @@
# Testing Reference
Use `search-docs` for authoritative documentation on testing Cashier integrations.
## Test Cards and Tokens
Use card numbers for browser-based flows (Stripe.js / Checkout). Use `pm_card_*` tokens directly in feature tests that call the Stripe API.
| Card Number | Token | Behavior |
|---|---|---|
| `4242 4242 4242 4242` | `pm_card_visa` | Succeeds immediately |
| `4000 0025 0000 3155` | `pm_card_threeDSecure2Required` | Requires SCA/3DS |
| `4000 0027 6000 3184` | `pm_card_authenticationRequired` | Requires authentication |
| `4000 0000 0000 9995` | `pm_card_chargeDeclinedInsufficientFunds` | Declined, insufficient funds |
| `4000 0000 0000 0002` | `pm_card_chargeDeclined` | Declined |
Use expiry `12/34`, any CVC, any ZIP for card number inputs.
## Feature Test Example
Feature tests that hit the real Stripe test API use `pm_card_*` tokens:
```php
public function test_user_can_subscribe(): void
{
$user = User::factory()->create();
$user->newSubscription('default', 'price_xxxx')
->create('pm_card_visa');
$this->assertTrue($user->subscribed('default'));
}
public function test_incomplete_payment_is_handled(): void
{
$user = User::factory()->create();
try {
$user->newSubscription('default', 'price_xxxx')
->create('pm_card_threeDSecure2Required');
} catch (\Laravel\Cashier\Exceptions\IncompletePayment $e) {
$this->assertTrue($user->subscription('default')->incomplete());
}
}
```
## Setup Notes
- Use Stripe test mode keys (`sk_test_...`, `pk_test_...`) in your test environment
- Cashier does not ship a global `fake()` helper. Tests hit the real Stripe test API by default.
- Refer to `tests/Feature/` in the Cashier package itself for integration test patterns covering subscription creation, payment methods, and webhook handling
- Use `search-docs` for current guidance on mocking Stripe HTTP calls or using Stripe's test clock feature for time-sensitive scenarios

View File

@ -1,132 +0,0 @@
# Webhooks Reference
Use `search-docs` for authoritative documentation on webhooks.
## Auto-Registered Routes
Cashier registers two routes automatically under the `cashier.path` prefix (`config('cashier.path')`, default `stripe`):
- `POST /{cashier.path}/webhook` named `cashier.webhook`
- `GET /{cashier.path}/payment/{id}` named `cashier.payment`
With the default config these are `/stripe/webhook` and `/stripe/payment/{id}`. If you set `CASHIER_PATH=billing`, they become `/billing/webhook` and `/billing/payment/{id}`.
## CSRF Exclusion
Use the same path prefix you configured for Cashier here. If `CASHIER_PATH=billing`, exclude `billing/*` instead of `stripe/*`.
**Laravel 11+ (`bootstrap/app.php`, default path example):**
```php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['stripe/*']);
})
```
**Laravel 10 (`app/Http/Middleware/VerifyCsrfToken.php`, default path example):**
```php
protected $except = [
'stripe/*',
];
```
## Local Development with Stripe CLI
If you changed `cashier.path`, forward Stripe CLI events to that URL instead of `/stripe/webhook`.
```bash
stripe login
stripe listen --forward-to your-app.test/stripe/webhook
stripe trigger invoice.payment_succeeded
```
The CLI outputs a `whsec_...` signing secret specific to that session. Set it as `STRIPE_WEBHOOK_SECRET` locally. It is not the same as the Dashboard endpoint secret.
## Registering Events in the Stripe Dashboard
Use the Artisan command to create the endpoint automatically with all required events:
```bash
php artisan cashier:webhook
```
Cashier's `cashier:webhook` command registers these events by default:
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `customer.updated` / `customer.deleted`
- `invoice.payment_action_required`
- `invoice.payment_succeeded`
- `payment_method.automatically_updated`
Cashier's `WebhookController` has built-in handlers for all of the above except `invoice.payment_succeeded`. For renewal hooks, prefer `WebhookReceived` / `WebhookHandled` listeners unless you intentionally add your own controller method.
## Custom Handlers: Extending WebhookController
Method name pattern: `handle` + StudlyCase of event type with dots replaced by underscores.
`customer.subscription.created` becomes `handleCustomerSubscriptionCreated`.
```php
use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;
class StripeWebhookController extends CashierController
{
public function handleCustomerSubscriptionCreated(array $payload)
{
$response = parent::handleCustomerSubscriptionCreated($payload);
// your logic after Cashier syncs the subscription
return $response;
}
}
```
If you add a method for an event Cashier does not handle internally, such as `invoice.payment_succeeded`, do not call `parent::handle...()` unless the base controller actually defines that method.
In a service provider, disable auto-registration and re-register both Cashier routes so the incomplete-payment flow and `cashier:webhook` command keep working:
```php
Cashier::ignoreRoutes();
```
```php
// routes/web.php
use App\Http\Controllers\StripeWebhookController;
use Illuminate\Support\Facades\Route;
use Laravel\Cashier\Http\Controllers\PaymentController;
Route::prefix(config('cashier.path'))
->name('cashier.')
->group(function () {
Route::get('payment/{id}', [PaymentController::class, 'show'])->name('payment');
Route::post('webhook', [StripeWebhookController::class, 'handleWebhook'])->name('webhook');
});
```
Keep the `cashier.webhook` route name unless you plan to pass `--url` explicitly to `php artisan cashier:webhook`.
## Custom Handlers: Listening to Events
The simpler option when you do not need to replace Cashier's internal logic, or when you want to react to events such as `invoice.payment_succeeded` that Cashier does not process itself:
```php
use Laravel\Cashier\Events\WebhookReceived;
use Laravel\Cashier\Events\WebhookHandled;
// WebhookReceived fires for every event before Cashier processes it
// WebhookHandled fires after Cashier processes it
Event::listen(WebhookReceived::class, function (WebhookReceived $event) {
if ($event->payload['type'] === 'invoice.payment_succeeded') {
// handle renewal
}
});
```
## Signature Verification
`VerifyWebhookSignature` middleware is applied automatically when `cashier.webhook.secret` is set. No extra wiring is needed.

View File

@ -0,0 +1,128 @@
---
name: developing-with-fortify
description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
---
# Laravel Fortify Development
Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
## Documentation
Use `search-docs` for detailed Laravel Fortify patterns and documentation.
## Usage
- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
## Available Features
Enable in `config/fortify.php` features array:
- `Features::registration()` - User registration
- `Features::resetPasswords()` - Password reset via email
- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
- `Features::updateProfileInformation()` - Profile updates
- `Features::updatePasswords()` - Password changes
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
> Use `search-docs` for feature configuration options and customization patterns.
## Setup Workflows
### Two-Factor Authentication Setup
```
- [ ] Add TwoFactorAuthenticatable trait to User model
- [ ] Enable feature in config/fortify.php
- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
- [ ] Set up view callbacks in FortifyServiceProvider
- [ ] Create 2FA management UI
- [ ] Test QR code and recovery codes
```
> Use `search-docs` for TOTP implementation and recovery code handling patterns.
### Email Verification Setup
```
- [ ] Enable emailVerification feature in config
- [ ] Implement MustVerifyEmail interface on User model
- [ ] Set up verifyEmailView callback
- [ ] Add verified middleware to protected routes
- [ ] Test verification email flow
```
> Use `search-docs` for MustVerifyEmail implementation patterns.
### Password Reset Setup
```
- [ ] Enable resetPasswords feature in config
- [ ] Set up requestPasswordResetLinkView callback
- [ ] Set up resetPasswordView callback
- [ ] Define password.reset named route (if views disabled)
- [ ] Test reset email and link flow
```
> Use `search-docs` for custom password reset flow patterns.
### SPA Authentication Setup
```
- [ ] Set 'views' => false in config/fortify.php
- [ ] Install and configure Laravel Sanctum for session-based SPA authentication
- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication)
- [ ] Set up CSRF token handling
- [ ] Test XHR authentication flows
```
> Use `search-docs` for integration and SPA authentication patterns.
#### Two-Factor Authentication in SPA Mode
When `views` is set to `false`, Fortify returns JSON responses instead of redirects.
If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required:
```json
{
"two_factor": true
}
```
## Best Practices
### Custom Authentication Logic
Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
### Registration Customization
Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
### Rate Limiting
Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
## Key Endpoints
| Feature | Method | Endpoint |
|------------------------|----------|---------------------------------------------|
| Login | POST | `/login` |
| Logout | POST | `/logout` |
| Register | POST | `/register` |
| Password Reset Request | POST | `/forgot-password` |
| Password Reset | POST | `/reset-password` |
| Email Verify Notice | GET | `/email/verify` |
| Resend Verification | POST | `/email/verification-notification` |
| Password Confirm | POST | `/user/confirm-password` |
| Enable 2FA | POST | `/user/two-factor-authentication` |
| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
| 2FA Challenge | POST | `/two-factor-challenge` |
| Get QR Code | GET | `/user/two-factor-qr-code` |
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |

View File

@ -15,7 +15,7 @@ Activate this skill when:
- Creating or modifying React page components for Inertia
- Working with forms in React (using `<Form>` or `useForm`)
- Implementing client-side navigation with `<Link>` or `router`
- Using v2 features: deferred props, prefetching, WhenVisible, InfiniteScroll, once props, flash data, or polling
- Using v2 features: deferred props, prefetching, or polling
- Building React-specific features with the Inertia protocol
## Documentation
@ -295,21 +295,14 @@ export default function UsersIndex({ users }) {
### Polling
Automatically refresh data at intervals:
Use the `usePoll` hook to automatically refresh data at intervals. It handles cleanup on unmount and throttles polling when the tab is inactive.
<!-- Polling Example -->
<!-- Basic Polling -->
```react
import { router } from '@inertiajs/react'
import { useEffect } from 'react'
import { usePoll } from '@inertiajs/react'
export default function Dashboard({ stats }) {
useEffect(() => {
const interval = setInterval(() => {
router.reload({ only: ['stats'] })
}, 5000) // Poll every 5 seconds
return () => clearInterval(interval)
}, [])
usePoll(5000)
return (
<div>
@ -320,37 +313,64 @@ export default function Dashboard({ stats }) {
}
```
### WhenVisible
Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
<!-- WhenVisible Example -->
<!-- Polling With Request Options and Manual Control -->
```react
import { WhenVisible } from '@inertiajs/react'
import { usePoll } from '@inertiajs/react'
export default function Dashboard({ stats }) {
const { start, stop } = usePoll(5000, {
only: ['stats'],
onStart() {
console.log('Polling request started')
},
onFinish() {
console.log('Polling request finished')
},
}, {
autoStart: false,
keepAlive: true,
})
return (
<div>
<h1>Dashboard</h1>
{/* stats prop is loaded only when this section scrolls into view */}
<WhenVisible data="stats" buffer={200} fallback={<div className="animate-pulse">Loading stats...</div>}>
{({ fetching }) => (
<div>
<p>Total Users: {stats.total_users}</p>
<p>Revenue: {stats.revenue}</p>
{fetching && <span>Refreshing...</span>}
</div>
)}
</WhenVisible>
<div>Active Users: {stats.activeUsers}</div>
<button onClick={start}>Start Polling</button>
<button onClick={stop}>Stop Polling</button>
</div>
)
}
```
## Server-Side Patterns
- `autoStart` (default `true`) — set to `false` to start polling manually via the returned `start()` function
- `keepAlive` (default `false`) — set to `true` to prevent throttling when the browser tab is inactive
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.
### WhenVisible (Infinite Scroll)
Load more data when user scrolls to a specific element:
<!-- Infinite Scroll with WhenVisible -->
```react
import { WhenVisible } from '@inertiajs/react'
export default function UsersList({ users }) {
return (
<div>
{users.data.map(user => (
<div key={user.id}>{user.name}</div>
))}
{users.next_page_url && (
<WhenVisible
data="users"
params={{ page: users.current_page + 1 }}
fallback={<div>Loading more...</div>}
/>
)}
</div>
)
}
```
## Common Pitfalls

View File

@ -1,6 +1,6 @@
---
name: pennant-development
description: "Use when working with Laravel Pennant the official Laravel feature flag package. Trigger whenever the query mentions Pennant by name or involves feature flags or feature toggles in a Laravel project. Tasks include defining feature flags checking whether features are active creating class based features in `app/Features` using Blade `@feature` directives scoping flags to users or teams building custom Pennant storage drivers protecting routes with feature flags testing feature flags with Pest or PHPUnit and implementing A B testing or gradual rollouts with feature flags. Do not trigger for generic Laravel configuration authorization policies authentication or non Pennant feature management systems."
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."
license: MIT
metadata:
author: laravel

View File

@ -1,6 +1,6 @@
---
name: pest-testing
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
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."
license: MIT
metadata:
author: laravel
@ -8,6 +8,16 @@ metadata:
# 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.

View File

@ -1,6 +1,6 @@
---
name: tailwindcss-development
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
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."
license: MIT
metadata:
author: laravel
@ -8,6 +8,16 @@ metadata:
# 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.

View File

@ -8,6 +8,13 @@ metadata:
# 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.

57
.github/workflows/automerge.yml vendored Normal file
View File

@ -0,0 +1,57 @@
name: automerge
on:
workflow_run:
workflows: ['CI']
types: [completed]
jobs:
automerge:
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Merge PR with automerge label
env:
GH_TOKEN: ${{ secrets.MERGE_TOKEN }}
REPO: ${{ github.repository }}
run: |
HEAD_BRANCH="${{ github.event.workflow_run.head_branch }}"
PR_NUMBER=$(gh pr list --repo "$REPO" --head "$HEAD_BRANCH" --state open --json number --jq '.[0].number')
if [ -z "$PR_NUMBER" ] || [ "$PR_NUMBER" = "null" ]; then
echo "No open PR found for branch $HEAD_BRANCH"
exit 0
fi
HAS_LABEL=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json labels --jq '[.labels[].name] | map(select(. == "automerge")) | length')
if [ "$HAS_LABEL" -eq 0 ]; then
echo "PR #$PR_NUMBER does not have automerge label, skipping"
exit 0
fi
# Ensure the CI run matches the PR's latest commit to avoid merging stale results
CI_SHA="${{ github.event.workflow_run.head_sha }}"
PR_SHA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid --jq '.headRefOid')
if [ "$CI_SHA" != "$PR_SHA" ]; then
echo "CI ran on $CI_SHA but PR head is $PR_SHA, skipping"
exit 0
fi
# Ensure no checks have failed on the PR
FAILED=$(gh pr checks "$PR_NUMBER" --repo "$REPO" --json state --jq '[.[] | select(.state == "FAILURE")] | length')
if [ "$FAILED" -gt 0 ]; then
echo "PR #$PR_NUMBER has $FAILED failed check(s), skipping merge"
exit 0
fi
echo "PR #$PR_NUMBER has automerge label and all checks passed. Merging..."
gh pr merge "$PR_NUMBER" --squash --repo "$REPO" --delete-branch

View File

@ -64,9 +64,6 @@ jobs:
- name: Generate Application Key
run: php artisan key:generate
- name: Generate Passport Keys
run: php artisan passport:keys --force
- name: Tests
# --parallel splits the suite across workers via paratest. Each
# worker creates its own "testing_test_<N>" database; the mysql
@ -147,9 +144,6 @@ jobs:
- name: Generate Application Key
run: php artisan key:generate
- name: Generate Passport Keys
run: php artisan passport:keys --force
- name: Browser Tests
run: ./vendor/bin/pest --testsuite=Browser --ci --shard=${{ matrix.shard }}/6
env:
@ -241,9 +235,6 @@ jobs:
linter:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@v4
@ -271,44 +262,9 @@ jobs:
- name: Lint Frontend
run: bun run lint
# Advisory for now: the codebase carries a backlog of pre-existing
# `tsc --noEmit` errors that predate this step, so it must not gate
# merges yet. It still surfaces type regressions in the CI log. Flip
# `continue-on-error` to false once the existing backlog is cleared.
- name: Type Check Frontend
run: bun run types
continue-on-error: true
- name: Frontend Tests
run: bun run test
# Gates merges on a Conventional-Commit PR title. Lives in the linter
# job (a required check) so a bad title blocks the merge, unlike the old
# standalone workflow whose check wasn't required. Skipped on push since
# there's no PR title to validate then.
- name: Conventional PR title
if: github.event_name == 'pull_request'
uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
chore
docs
style
refactor
perf
test
build
ci
revert
requireScope: false
subjectPattern: ^(?![A-Z]).+$
subjectPatternError: |
Subject must not start with uppercase. Use: feat: add checkout, fix(billing): handle retry.
performance-tests:
runs-on: ubuntu-latest
services:
@ -393,10 +349,6 @@ jobs:
timeout-minutes: 45
needs: [tests, linter, static-analysis, performance-tests, changes]
if: ((github.ref == 'refs/heads/main' && github.event_name == 'push') || github.event_name == 'workflow_dispatch') && needs.changes.outputs.code == 'true'
outputs:
development-digest: ${{ steps.development-image.outputs.digest }}
production-digest: ${{ steps.production-image.outputs.digest }}
package-version: ${{ steps.package-version.outputs.version }}
env:
SENTRY_RELEASE: whisper-money@${{ github.sha }}
permissions:
@ -458,7 +410,6 @@ jobs:
type=raw,value=v${{ steps.package-version.outputs.version }}-production
- name: Build and push development image
id: development-image
uses: docker/build-push-action@v6
timeout-minutes: 20
with:
@ -473,7 +424,6 @@ jobs:
cache-to: type=gha,mode=max
- name: Build and push production image
id: production-image
uses: docker/build-push-action@v6
timeout-minutes: 25
with:
@ -488,108 +438,93 @@ jobs:
cache-from: type=gha,scope=production
cache-to: type=gha,mode=max,scope=production
build-arm64-images:
runs-on: ubuntu-24.04-arm
timeout-minutes: 75
needs: build-image
if: needs.build-image.result == 'success'
deploy:
runs-on: ubuntu-latest
needs: [build-image, changes]
if: github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.changes.outputs.code == 'true'
env:
SENTRY_RELEASE: whisper-money@${{ github.sha }}
permissions:
contents: read
packages: write
concurrency:
group: production-deploy-main
cancel-in-progress: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=
type=raw,value=latest
- name: Extract production metadata
id: production-meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=,suffix=-production
type=raw,value=production
type=raw,value=v${{ needs.build-image.outputs.package-version }}-production
- name: Build and push arm64 development image by digest
id: development-arm64
uses: docker/build-push-action@v6
timeout-minutes: 30
with:
context: .
platforms: linux/arm64
tags: ghcr.io/${{ github.repository }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
build-args: |
SENTRY_RELEASE=${{ env.SENTRY_RELEASE }}
cache-from: type=gha,scope=arm64
cache-to: type=gha,mode=max,scope=arm64
- name: Publish multi-platform development manifests
env:
AMD64_DIGEST: ${{ needs.build-image.outputs.development-digest }}
ARM64_DIGEST: ${{ steps.development-arm64.outputs.digest }}
IMAGE: ghcr.io/${{ github.repository }}
TAGS: ${{ steps.meta.outputs.tags }}
- name: Check if this is the latest main commit
id: deploy_guard
run: |
while IFS= read -r tag; do
[ -n "$tag" ] || continue
latest_main_sha=$(git ls-remote origin refs/heads/main | cut -f1)
docker buildx imagetools create \
--tag "$tag" \
"$IMAGE@$AMD64_DIGEST" \
"$IMAGE@$ARM64_DIGEST"
done <<< "$TAGS"
echo "Current workflow SHA: ${{ github.sha }}"
echo "Latest main SHA: $latest_main_sha"
- name: Build and push arm64 production image by digest
id: production-arm64
uses: docker/build-push-action@v6
timeout-minutes: 40
with:
context: .
file: Dockerfile.production
platforms: linux/arm64
tags: ghcr.io/${{ github.repository }}
labels: ${{ steps.production-meta.outputs.labels }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
build-args: |
SENTRY_RELEASE=${{ env.SENTRY_RELEASE }}
cache-from: type=gha,scope=production-arm64
cache-to: type=gha,mode=max,scope=production-arm64
if [ -z "$latest_main_sha" ]; then
echo "Unable to determine the latest main SHA."
exit 1
fi
- name: Publish multi-platform production manifests
env:
AMD64_DIGEST: ${{ needs.build-image.outputs.production-digest }}
ARM64_DIGEST: ${{ steps.production-arm64.outputs.digest }}
IMAGE: ghcr.io/${{ github.repository }}
TAGS: ${{ steps.production-meta.outputs.tags }}
if [ "$latest_main_sha" != "${{ github.sha }}" ]; then
echo "This workflow is not for the latest commit on main. Skipping deploy."
echo "should_deploy=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "This workflow is for the latest commit on main. Proceeding with deploy."
echo "should_deploy=true" >> "$GITHUB_OUTPUT"
- name: Trigger deployment
if: steps.deploy_guard.outputs.should_deploy == 'true'
run: |
while IFS= read -r tag; do
[ -n "$tag" ] || continue
max_attempts=5
attempt=1
docker buildx imagetools create \
--tag "$tag" \
"$IMAGE@$AMD64_DIGEST" \
"$IMAGE@$ARM64_DIGEST"
done <<< "$TAGS"
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt of $max_attempts..."
response=$(curl -s -w "\n%{http_code}" \
--connect-timeout 30 \
--max-time 120 \
-H "Authorization: Bearer ${{ secrets.DEPLOYMENT_TOKEN }}" \
"http://147.93.126.54:8000/api/v1/deploy?uuid=ww00sswosco8w80k08c0occ8&force=false") || true
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
echo "Response: $body"
echo "HTTP Status: ${http_code:-timeout}"
if [ -n "$http_code" ] && [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
echo "Deployment triggered successfully!"
exit 0
fi
echo "Deployment request failed with status $http_code"
if [ $attempt -lt $max_attempts ]; then
delay=$((30 * (2 ** (attempt - 1))))
echo "Retrying in ${delay}s..."
sleep $delay
fi
attempt=$((attempt + 1))
done
echo "All $max_attempts attempts failed. Giving up."
exit 1
- name: Install Sentry CLI
if: steps.deploy_guard.outputs.should_deploy == 'true'
run: curl -sL https://sentry.io/get-cli/ | bash
- name: Mark Sentry release deployed
if: steps.deploy_guard.outputs.should_deploy == 'true'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: sentry-cli releases deploys "$SENTRY_RELEASE" new -e production
- name: Deployment skipped
if: steps.deploy_guard.outputs.should_deploy == 'false'
run: echo "Skipped deployment because a newer commit is already on main."

View File

@ -1,8 +1,6 @@
name: Release
on:
schedule:
- cron: "0 8 * * 1" # Mondays 08:00 UTC = 10:00 Madrid (CEST) / 09:00 (CET)
workflow_dispatch:
inputs:
increment:
@ -44,38 +42,23 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Check for releasable commits
id: guard
run: |
LAST=$(git describe --tags --abbrev=0)
if [ -z "$(git log "$LAST"..HEAD --oneline)" ]; then
echo "No commits since $LAST; skipping release."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Create release branch
id: branch
if: steps.guard.outputs.skip != 'true'
run: |
BRANCH="release/run-${{ github.run_id }}"
git checkout -b "$BRANCH"
echo "name=$BRANCH" >> "$GITHUB_OUTPUT"
- name: Run release-it
if: steps.guard.outputs.skip != 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx release-it "${{ inputs.increment || 'patch' }}" --ci
run: npx release-it ${{ inputs.increment }} --ci
- name: Read new version
id: version
if: steps.guard.outputs.skip != 'true'
run: echo "value=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Open pull request
if: steps.guard.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |

1
.gitignore vendored
View File

@ -32,4 +32,3 @@ yarn-error.log
.claude/settings.local.json
.php-version
.pint.cache
/.playwright-mcp

View File

@ -15,8 +15,7 @@ Goal:
Rules:
- Protect local work. Start with `git status --short`. If uncommitted changes exist, stop and ask before branching.
- Never expose secrets. Do not print tokens, `.env`, Sentry auth, DB URLs, or PII.
- Use the Sentry CLI (`sentry`) first. Prefer stored OAuth login over env build tokens; if `SENTRY_AUTH_TOKEN` is invalid or too narrow, run CLI commands as `env -u SENTRY_AUTH_TOKEN sentry ...` unless `SENTRY_FORCE_ENV_TOKEN=1` is intentionally set.
- Never print raw Sentry JSON that may contain PII. Redact emails, user IDs when summarizing. Keep secrets out of output.
- Use Sentry MCP tools first: `find_organizations`, `find_projects`, `search_issues`, `get_sentry_resource`, `search_issue_events`, `get_issue_tag_values`, `get_replay_details`, `get_profile_details`, `analyze_issue_with_seer` when root cause unclear.
- For Laravel ecosystem changes, use `application-info` and `search-docs` before code changes.
- Activate/read relevant project skills when touched: Pest tests, Inertia React, Wayfinder, Tailwind, Fortify, Pennant.
- Every code change needs programmatic verification. Add or update a focused Pest/test when feasible. Run minimum affected tests. Run `vendor/bin/pint --dirty --format agent` after PHP edits.
@ -24,24 +23,17 @@ Rules:
Workflow:
1. Identify issue:
- Confirm auth and org/project access with `sentry auth status`, `sentry org list --json`, and `sentry project list <org> --json` when needed.
- If `$ARGUMENTS` is a Sentry URL, extract `/issues/<numeric-id>/` or the visible short issue id, then fetch it with `sentry issue view <issue> --json --fields id,shortId,title,culprit,permalink,level,status,substatus,count,userCount,firstSeen,lastSeen,project,metadata,priority,platform,isUnhandled`.
- If `$ARGUMENTS` is a bare issue id or short id, fetch it with `sentry issue view <issue> --json --fields id,shortId,title,culprit,permalink,level,status,substatus,count,userCount,firstSeen,lastSeen,project,metadata,priority,platform,isUnhandled`.
- If no args, list unresolved production issues with both frequency and user sorting, then compare top results:
- `sentry issue list <org>/<project> --query 'is:unresolved environment:production' --sort freq --limit 25 --json --fields id,shortId,title,culprit,count,userCount,lastSeen,permalink,priority,metadata,project`
- `sentry issue list <org>/<project> --query 'is:unresolved environment:production' --sort user --limit 25 --json --fields id,shortId,title,culprit,count,userCount,lastSeen,permalink,priority,metadata,project`
- Pick best impact score: high event count, high user count, recent lastSeen, production environment, clear actionable stack.
- If `$ARGUMENTS` is a Sentry URL, fetch it with `get_sentry_resource(url=...)`.
- If `$ARGUMENTS` is a bare issue id, discover org/project if needed, then fetch issue with `get_sentry_resource(resourceType="issue", resourceId=<id>, organizationSlug=<org>)`.
- If no args, find org/project, search unresolved production issues with both frequency and user sorting, compare top results, and pick one with best impact score: high event count, high user count, recent lastSeen, production environment, clear actionable stack.
- Record chosen short issue id and Sentry URL in notes.
2. Branch:
- Derive branch name from Sentry short issue id only, e.g. `WHISPER-MONEY-123`.
- Run `git switch -c <issue-id>`; if branch exists, `git switch <issue-id>`.
3. Investigate:
- Fetch latest issue details and spans with `sentry issue view <issue> --spans all --json`.
- Fetch recent events with `sentry issue events <issue> --limit 10 --full --json`.
- Extract stack trace, breadcrumbs, environment, release, tags, URLs/routes, affected users count, trace/span data, and suspect queries. Redact PII in notes.
- For event details if needed, use `sentry event view <event-id> --json` or `sentry api <endpoint> --json`.
- Fetch latest events, stack trace, breadcrumbs, environment, release, tags, URLs/routes, affected users count, and relevant traces/replays/profiles if present.
- Reproduce locally using tests or focused command. Inspect app logs/browser logs if relevant.
- If root cause not obvious after issue/event data, run Seer with `sentry issue explain <issue> --json` and/or `sentry issue plan <issue> --json`.
- If root cause not obvious after issue/event data, run Seer analysis.
4. Fix:
- Read nearby code and conventions first.
- Implement minimal fix.

View File

@ -1,7 +1,4 @@
{
"hooks": {
"after:bump": "node scripts/enrich-changelog.js && git add CHANGELOG.md"
},
"git": {
"commitMessage": "chore: release v${version}",
"tagName": "v${version}",

View File

@ -18,15 +18,14 @@ 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
- php - 8.4.17
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
- laravel/cashier (CASHIER) - v16
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v13
- laravel/framework (LARAVEL) - v12
- laravel/pennant (PENNANT) - v1
- laravel/prompts (PROMPTS) - v0
- laravel/wayfinder (WAYFINDER) - v0
- larastan/larastan (LARASTAN) - v3
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
@ -45,13 +44,12 @@ This application is a Laravel application and its main Laravel ecosystems packag
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.
- `cashier-stripe-development` — Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues.
- `pennant-development` — Use when working with Laravel Pennant the official Laravel feature flag package. Trigger whenever the query mentions Pennant by name or involves feature flags or feature toggles in a Laravel project. Tasks include defining feature flags checking whether features are active creating class based features in `app/Features` using Blade `@feature` directives scoping flags to users or teams building custom Pennant storage drivers protecting routes with feature flags testing feature flags with Pest or PHPUnit and implementing A B testing or gradual rollouts with feature flags. Do not trigger for generic Laravel configuration authorization policies authentication or non Pennant feature management systems.
- `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`Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
- `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`Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
- `fortify-development` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
- `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 &lt;Link&gt;, &lt;Form&gt;, 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.
- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
## Conventions
@ -86,23 +84,19 @@ This project has domain-specific skills available. You MUST activate the relevan
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan Commands
## Artisan
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`, `php artisan tinker --execute "..."`).
- Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
- 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.
## Debugging
## 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.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
- To execute PHP code for debugging, run `php artisan tinker --execute "your code here"` directly.
- To read configuration values, read the config files directly or run `php artisan config:show [key]`.
- To inspect routes, run `php artisan route:list` directly.
- To check environment variables, read the `.env` file directly.
## Reading Browser Logs With the `browser-logs` Tool
@ -133,7 +127,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
@ -142,6 +136,7 @@ This project has domain-specific skills available. You MUST activate the relevan
- Use appropriate PHP type hints for method parameters.
<!-- Explicit Return Types and Method Params -->
```php
protected function isAccessible(User $user, ?string $path = null): bool
{
@ -168,7 +163,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
- 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-laravel/v2 rules ===
# Inertia
@ -180,14 +175,14 @@ protected function isAccessible(User $user, ?string $path = null): bool
# 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 scroll, merging props, polling, prefetching, once props, flash data.
- 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 `php artisan list` and check their parameters with `php artisan [command] --help`.
- 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.
@ -201,7 +196,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
- 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
@ -238,6 +233,39 @@ protected function isAccessible(User $user, ?string $path = null): bool
- 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
@ -264,6 +292,8 @@ Wayfinder generates TypeScript functions for Laravel routes. Import from `@/acti
- 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 ===
@ -271,6 +301,14 @@ Wayfinder generates TypeScript functions for Laravel routes. Import from `@/acti
- 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

View File

@ -1,230 +1,5 @@
# Changelog
## [0.2.6](https://github.com/whisper-money/whisper-money/compare/v0.2.5...v0.2.6) (2026-07-06)
### Bug Fixes
* **accounts:** fire drag haptic on first tap, not after dragging ([#576](https://github.com/whisper-money/whisper-money/issues/576)) ([c75e834](https://github.com/whisper-money/whisper-money/commit/c75e834b89b58f19fba0021e423ef5d7fe8ae827))
* **accounts:** remove double-skeleton flash on account show ([#634](https://github.com/whisper-money/whisper-money/issues/634)) ([afa80b6](https://github.com/whisper-money/whisper-money/commit/afa80b60fe97ecc504847ef9c4a1e8c9a9c2c9f1)), closes [#632](https://github.com/whisper-money/whisper-money/issues/632)
* **accounts:** stop second long-press haptic on drag handle ([#578](https://github.com/whisper-money/whisper-money/issues/578)) ([5db6cdc](https://github.com/whisper-money/whisper-money/commit/5db6cdc6d2fcc03d3d89d16d058834cb89107999)), closes [#576](https://github.com/whisper-money/whisper-money/issues/576)
* address remaining security audit findings (round 2) ([#628](https://github.com/whisper-money/whisper-money/issues/628)) ([4fdb7ee](https://github.com/whisper-money/whisper-money/commit/4fdb7eebd916f81bfde7dc4c6adbc8387d6699ca)), closes [#623](https://github.com/whisper-money/whisper-money/issues/623)
* **ai:** handle transient AI provider overloads — stop the Sentry noise and retry the dropped work ([#595](https://github.com/whisper-money/whisper-money/issues/595)) ([4038e60](https://github.com/whisper-money/whisper-money/commit/4038e60fbcd9891ceecabe5f66c34dde1abcc6cd))
* **ai:** surface learned-rule toast in edit modal and guard weak description keys ([#635](https://github.com/whisper-money/whisper-money/issues/635)) ([c159e87](https://github.com/whisper-money/whisper-money/commit/c159e8782efa5808e1f707eb7309966de3144f9b))
* **analysis:** respect category types like the cashflow screen ([#612](https://github.com/whisper-money/whisper-money/issues/612)) ([986f437](https://github.com/whisper-money/whisper-money/commit/986f43705a35fb0e3cd49c2056c2a46e769a91aa))
* **appearance:** support MediaQueryList change events on legacy Safari (PHP-LARAVEL-41) ([#646](https://github.com/whisper-money/whisper-money/issues/646)) ([465eb38](https://github.com/whisper-money/whisper-money/commit/465eb38dae1e856f0017f7f0e33bbad31b1d2c71))
* **banking:** handle EnableBanking expired sessions as reconnect, not error ([#557](https://github.com/whisper-money/whisper-money/issues/557)) ([c36df98](https://github.com/whisper-money/whisper-money/commit/c36df98d326336c27cf63039eb28518dae3af43e))
* **banking:** keep the native green Wise logo, not the aggregator's ([#590](https://github.com/whisper-money/whisper-money/issues/590)) ([578a9b4](https://github.com/whisper-money/whisper-money/commit/578a9b44d8f01d4d22558397af2f31a9e5bf80b8)), closes [#589](https://github.com/whisper-money/whisper-money/issues/589) [#525](https://github.com/whisper-money/whisper-money/issues/525) [#589](https://github.com/whisper-money/whisper-money/issues/589)
* **banking:** only log sync failures once the connection gives up ([#603](https://github.com/whisper-money/whisper-money/issues/603)) ([8bbff05](https://github.com/whisper-money/whisper-money/commit/8bbff05b2693cef3edbc8fc4c9350f6ba7ab99d6))
* **banking:** skip inaccessible EnableBanking accounts instead of failing the connection ([#559](https://github.com/whisper-money/whisper-money/issues/559)) ([4656870](https://github.com/whisper-money/whisper-money/commit/46568700b2c0610566a7a35efe54810ea51e3925))
* **banking:** stop Wise appearing multiple times in the connect list ([#589](https://github.com/whisper-money/whisper-money/issues/589)) ([ed5aac0](https://github.com/whisper-money/whisper-money/commit/ed5aac0c4a0713e5bf2b0f2a82b9258abdcdb986))
* **charts:** improve contrast for chart colors 9-10 ([#614](https://github.com/whisper-money/whisper-money/issues/614)) ([a37481f](https://github.com/whisper-money/whisper-money/commit/a37481fb71b935f0f42b282fabe6db3c664be5c6)), closes [hi#index](https://github.com/hi/issues/index)
* **discord:** show old → new plan on plan change notification ([#637](https://github.com/whisper-money/whisper-money/issues/637)) ([3972007](https://github.com/whisper-money/whisper-money/commit/39720078447f9b17dacbfd15e9986f978b87b56a))
* **discord:** skip zero-amount payment stats messages ([#540](https://github.com/whisper-money/whisper-money/issues/540)) ([7693e48](https://github.com/whisper-money/whisper-money/commit/7693e4813f810c21836b292770590ec511224109))
* **i18n:** keep Discord brand name untranslated in French ([#541](https://github.com/whisper-money/whisper-money/issues/541)) ([06effb5](https://github.com/whisper-money/whisper-money/commit/06effb5e6ef2311657c5beaf9ec57918739eb38f))
* **integration-requests:** freeze votes on not-doable requests ([#555](https://github.com/whisper-money/whisper-money/issues/555)) ([89c1ab1](https://github.com/whisper-money/whisper-money/commit/89c1ab1ca8edf4afcab183e11d0b6fc7ab095952))
* **open-banking:** only block re-adding a bank when a live connection exists ([#569](https://github.com/whisper-money/whisper-money/issues/569)) ([14c4598](https://github.com/whisper-money/whisper-money/commit/14c4598cda6989017af9dd8551791d5a2c456a9d))
* **open-banking:** stop storing the XXX no-currency placeholder on accounts ([#602](https://github.com/whisper-money/whisper-money/issues/602)) ([bc57eae](https://github.com/whisper-money/whisper-money/commit/bc57eae5c34cdf37beb7a82b5569fb50d12e3ab7))
* **queue:** add supervisor worker for the ai queue ([#546](https://github.com/whisper-money/whisper-money/issues/546)) ([f191d74](https://github.com/whisper-money/whisper-money/commit/f191d740314836ae12b897b6e9f5151ecc39e15f))
* **queue:** raise retry_after above the longest job timeout (PHP-LARAVEL-2D) ([#645](https://github.com/whisper-money/whisper-money/issues/645)) ([05d4bae](https://github.com/whisper-money/whisper-money/commit/05d4bae0af1fece9196fabde61a624cf19472da9))
* **security:** scope job-status endpoints to owner + feature-area fixes ([#627](https://github.com/whisper-money/whisper-money/issues/627)) ([d55c3f4](https://github.com/whisper-money/whisper-money/commit/d55c3f41df9705fe533d13f3fa9da0cf7790cb4a)), closes [#4](https://github.com/whisper-money/whisper-money/issues/4) [#1](https://github.com/whisper-money/whisper-money/issues/1)
* **sentry:** drop browser-extension noise before sending events ([#568](https://github.com/whisper-money/whisper-money/issues/568)) ([52708f9](https://github.com/whisper-money/whisper-money/commit/52708f940df2a86bc1bf47afe72af08abd4e345f))
* **settings:** vertically center rows in automation rules and labels tables ([#615](https://github.com/whisper-money/whisper-money/issues/615)) ([e631cbb](https://github.com/whisper-money/whisper-money/commit/e631cbba69d8184f5efbb4c65d198a836a9ab883))
* skip demo reset when demo account is disabled ([#626](https://github.com/whisper-money/whisper-money/issues/626)) ([eb31455](https://github.com/whisper-money/whisper-money/commit/eb31455e606f8e0b965b6b3cd83d4e2c9de34df5))
* stop double-dispatching transaction listeners (N+1 insert into jobs) ([#620](https://github.com/whisper-money/whisper-money/issues/620)) ([0f8eca5](https://github.com/whisper-money/whisper-money/commit/0f8eca50d036aca21142fac39ca5ed385d2dbada))
* **transactions:** let date column size to its content ([#610](https://github.com/whisper-money/whisper-money/issues/610)) ([5ef3e01](https://github.com/whisper-money/whisper-money/commit/5ef3e01c8905795ca29600bab3f62578ffe0673d))
* **transactions:** only auto-select account on account pages ([#549](https://github.com/whisper-money/whisper-money/issues/549)) ([9a20335](https://github.com/whisper-money/whisper-money/commit/9a20335c6a4a7fa814f1460afbf8384888ee2e88))
* **transactions:** pad Category column when Date column is hidden ([#584](https://github.com/whisper-money/whisper-money/issues/584)) ([d2806b5](https://github.com/whisper-money/whisper-money/commit/d2806b5887753aca2d7ccce8b2360107541890bc)), closes [#583](https://github.com/whisper-money/whisper-money/issues/583) [#582](https://github.com/whisper-money/whisper-money/issues/582)
* **transactions:** pad Category column when Date column is hidden ([#585](https://github.com/whisper-money/whisper-money/issues/585)) ([8e38713](https://github.com/whisper-money/whisper-money/commit/8e3871370ae17e2b582effdb72218ba2ed65b40a)), closes [582/#583](https://github.com/whisper-money/whisper-money/issues/583)
* **transactions:** restore left padding when category is first column ([#582](https://github.com/whisper-money/whisper-money/issues/582)) ([d11aa2d](https://github.com/whisper-money/whisper-money/commit/d11aa2dfe579e0a9af27909d51ac776f975b3073))
* **ui:** make input borders visible in dark mode ([#571](https://github.com/whisper-money/whisper-money/issues/571)) ([83a5e96](https://github.com/whisper-money/whisper-money/commit/83a5e9657e679d0e849c9f5f532a021dce9807ff))
* **worktree:** remove double slash in storage/keys copy path ([#629](https://github.com/whisper-money/whisper-money/issues/629)) ([4615d7a](https://github.com/whisper-money/whisper-money/commit/4615d7a88002f2b3c7df847a27f783a710a69cc6))
### Features
* **accounts:** reorder accounts with drag-and-drop ([#575](https://github.com/whisper-money/whisper-money/issues/575)) ([cd3080e](https://github.com/whisper-money/whisper-money/commit/cd3080ec52fd2586f9920794559c7b500cbef6bf))
* add Wise open banking integration with balance sync ([#525](https://github.com/whisper-money/whisper-money/issues/525)) ([1c5a76a](https://github.com/whisper-money/whisper-money/commit/1c5a76a3a4cc9173d7ba2a7f8e3f67fc303e30d4))
* **ai:** dismissable AI consent banner that stops after the first decision ([#617](https://github.com/whisper-money/whisper-money/issues/617)) ([7e36bba](https://github.com/whisper-money/whisper-money/commit/7e36bbafef8faa076b25dc70451b7165b000349a))
* **ai:** learn from category corrections so the AI stops repeating the same mistake ([#608](https://github.com/whisper-money/whisper-money/issues/608)) ([6727a9c](https://github.com/whisper-money/whisper-money/commit/6727a9c64a7083ac962e4d489a36c61f4c4e590f))
* **ai:** manage AI consent outside onboarding with live backfill ([#591](https://github.com/whisper-money/whisper-money/issues/591)) ([9a458b1](https://github.com/whisper-money/whisper-money/commit/9a458b103131a0b848bdc17b5d015c923a30d71e))
* **ai:** persist AI categorization suggestions below the label bar ([#547](https://github.com/whisper-money/whisper-money/issues/547)) ([9328cd3](https://github.com/whisper-money/whisper-money/commit/9328cd3e1ba53218b521d6501d8750bb483a2ea6))
* **ai:** record the model behind each AI categorization ([#594](https://github.com/whisper-money/whisper-money/issues/594)) ([291cfbe](https://github.com/whisper-money/whisper-money/commit/291cfbe2612a3682f913216a64ebc76c699e55a2))
* **banking:** add Interactive Brokers sync via Flex Web Service ([#581](https://github.com/whisper-money/whisper-money/issues/581)) ([f60e6d7](https://github.com/whisper-money/whisper-money/commit/f60e6d70355d77b05ff4cad690cea65c1eb5792d))
* **banking:** enable Interactive Brokers for all users ([#593](https://github.com/whisper-money/whisper-money/issues/593)) ([094ff4d](https://github.com/whisper-money/whisper-money/commit/094ff4d5ac1e4ebab787056b8794654ac49f0cd8))
* **banking:** let Wise credentials be updated ([#588](https://github.com/whisper-money/whisper-money/issues/588)) ([619ed0f](https://github.com/whisper-money/whisper-money/commit/619ed0f1db44f004b8e4704e2a4e1bbef0e56a74)), closes [#581](https://github.com/whisper-money/whisper-money/issues/581)
* **connections:** create a new account from the manage-accounts selector ([#560](https://github.com/whisper-money/whisper-money/issues/560)) ([6cb8d11](https://github.com/whisper-money/whisper-money/commit/6cb8d115631d41d01f0dee025092decc4c31e01c))
* **connections:** manage which accounts a bank connection syncs ([#558](https://github.com/whisper-money/whisper-money/issues/558)) ([a9b90a2](https://github.com/whisper-money/whisper-money/commit/a9b90a200efc66d85e20405872a857db829255cf))
* **currencies:** add Nigerian Naira (NGN) ([#642](https://github.com/whisper-money/whisper-money/issues/642)) ([6ff7edf](https://github.com/whisper-money/whisper-money/commit/6ff7edf193bfb9baf51b4907a9fe0da49a95f533))
* **currency:** add GHS (Ghanaian Cedi) ([#644](https://github.com/whisper-money/whisper-money/issues/644)) ([2aebe45](https://github.com/whisper-money/whisper-money/commit/2aebe45d1f5a481760fd2c36ec98436b29d1c510)), closes [#567](https://github.com/whisper-money/whisper-money/issues/567)
* **currency:** add RSD (Serbian Dinar) ([#567](https://github.com/whisper-money/whisper-money/issues/567)) ([934e16c](https://github.com/whisper-money/whisper-money/commit/934e16c0fa2659a7e701d6cfcc23cbaad67d0c13))
* **dashboard:** add accounts manager dialog with visibility toggle and reorder ([#604](https://github.com/whisper-money/whisper-money/issues/604)) ([777dfc0](https://github.com/whisper-money/whisper-money/commit/777dfc07b281a5df522fc93154b73e6d7c72d2ef))
* **demo:** gate demo account access behind a config flag ([#580](https://github.com/whisper-money/whisper-money/issues/580)) ([a346566](https://github.com/whisper-money/whisper-money/commit/a346566fd0a6398bb7e9b146617f88d0d218a35f))
* **drip:** email users stuck on the paywall a day after onboarding ([#562](https://github.com/whisper-money/whisper-money/issues/562)) ([ce6bfc9](https://github.com/whisper-money/whisper-money/commit/ce6bfc9c562195c7dc89028fe3a920a814ff1b7a))
* **email:** follow up after post-onboarding AI consent ([#596](https://github.com/whisper-money/whisper-money/issues/596)) ([934d834](https://github.com/whisper-money/whisper-money/commit/934d834ab3dd19d66525fbd883d30de1b3d77982))
* **encryption:** commands to warn and remove inactive encrypted-data accounts ([#633](https://github.com/whisper-money/whisper-money/issues/633)) ([477e4d5](https://github.com/whisper-money/whisper-money/commit/477e4d50e26760d387dee1784db7093c05ce593e))
* **features:** support percentage rollouts in feature:enable ([#592](https://github.com/whisper-money/whisper-money/issues/592)) ([f72e2a6](https://github.com/whisper-money/whisper-money/commit/f72e2a64ca1101a04ddc3c3384f5f7c75aed8e5d))
* **i18n:** add French translation support ([#532](https://github.com/whisper-money/whisper-money/issues/532)) ([a38ed69](https://github.com/whisper-money/whisper-money/commit/a38ed69d2e134651ceddae5082fd2d70493b83e0))
* **integration-requests:** add done status and fix review command crash on orphaned author ([#601](https://github.com/whisper-money/whisper-money/issues/601)) ([e4be39b](https://github.com/whisper-money/whisper-money/commit/e4be39be1201b54894097e9580958d7cd23c4740))
* **integration-requests:** add not-doable status with a public comment ([#552](https://github.com/whisper-money/whisper-money/issues/552)) ([801f6c7](https://github.com/whisper-money/whisper-money/commit/801f6c7cd4834eeecbe77a078994c89845003a70))
* **integration-requests:** community board to request & vote bank integrations ([#550](https://github.com/whisper-money/whisper-money/issues/550)) ([5e8f227](https://github.com/whisper-money/whisper-money/commit/5e8f227fbdabbd15fd752645e7a1405803f032d9))
* **integration-requests:** let the admin bypass limits and auto-approve ([#551](https://github.com/whisper-money/whisper-money/issues/551)) ([7e9122e](https://github.com/whisper-money/whisper-money/commit/7e9122eaf442f0ea4cd2f2eecf1dfea1c8e7f7fd))
* **integration-requests:** markdown comments and in-progress status ([#553](https://github.com/whisper-money/whisper-money/issues/553)) ([da88adb](https://github.com/whisper-money/whisper-money/commit/da88adbee36c899fa24342d0b62c0cf1063df689))
* **integration-requests:** multi-vote, per-plan quota and undo ([#554](https://github.com/whisper-money/whisper-money/issues/554)) ([0ea54fa](https://github.com/whisper-money/whisper-money/commit/0ea54fa0d75dd268c7cb5e59f1da95a9a22976ee))
* **landing:** clarify AI framing and add testimonials ([#613](https://github.com/whisper-money/whisper-money/issues/613)) ([d55e15b](https://github.com/whisper-money/whisper-money/commit/d55e15bb4faabf34e4f1f92022db4a17fe085dec))
* **onboarding:** auto-enable AI for connected banks, ask the rest ([#618](https://github.com/whisper-money/whisper-money/issues/618)) ([10442c1](https://github.com/whisper-money/whisper-money/commit/10442c1e3293270dcc75a299414b793c5db14610))
* **onboarding:** clarify the "categorize at least 5" goal in the categorizer ([#616](https://github.com/whisper-money/whisper-money/issues/616)) ([af64f56](https://github.com/whisper-money/whisper-money/commit/af64f563991897723c69749ac69bf724b162f44c))
* **open-banking:** allow re-connecting a bank behind a replace warning ([#570](https://github.com/whisper-money/whisper-money/issues/570)) ([64827fa](https://github.com/whisper-money/whisper-money/commit/64827fabae82cadd98febdd457dcf0106934cc18)), closes [#569](https://github.com/whisper-money/whisper-money/issues/569)
* **open-banking:** disable already-connected banks in the connect picker ([#556](https://github.com/whisper-money/whisper-money/issues/556)) ([6e6433c](https://github.com/whisper-money/whisper-money/commit/6e6433c6ad8c6673edc5f263a76096bc4377da96))
* **open-banking:** enable manage bank accounts for everyone ([#572](https://github.com/whisper-money/whisper-money/issues/572)) ([0f3cdd4](https://github.com/whisper-money/whisper-money/commit/0f3cdd41aaa72a9544202a3d5add2515cf5ac6ab))
* **paywall:** require a plan when the user has accepted AI ([#564](https://github.com/whisper-money/whisper-money/issues/564)) ([29d13ce](https://github.com/whisper-money/whisper-money/commit/29d13ceed103860399b271ffed3cb17810112001))
* **stats:** add --no-discord flag to stats:experiment-funnel ([#606](https://github.com/whisper-money/whisper-money/issues/606)) ([1db2871](https://github.com/whisper-money/whisper-money/commit/1db2871398bd86deb0a3c48ba1f1881822e016cc))
* **stats:** add --no-discord to the remaining report commands ([#607](https://github.com/whisper-money/whisper-money/issues/607)) ([300756e](https://github.com/whisper-money/whisper-money/commit/300756e55341b84245efbbd37a038ae7edb7217b))
* **stats:** add weekly subscription funnel report ([#599](https://github.com/whisper-money/whisper-money/issues/599)) ([756b481](https://github.com/whisper-money/whisper-money/commit/756b4816a6d67922a15cf6b69e9933f8f1bea2c8))
* **stats:** weekly paywall stuck-cohort report to Discord ([#563](https://github.com/whisper-money/whisper-money/issues/563)) ([57f8c93](https://github.com/whisper-money/whisper-money/commit/57f8c93e2818ad53abcd8d1ad656aeb1f1edda4d))
* **subscriptions:** reframe pay_now paywall copy around try-and-refund ([#605](https://github.com/whisper-money/whisper-money/issues/605)) ([09d6e8e](https://github.com/whisper-money/whisper-money/commit/09d6e8ee6cfe6247439bfb3bfd475ab1a092e722))
* **subscriptions:** trial/pricing A/B/C experiment ([#600](https://github.com/whisper-money/whisper-money/issues/600)) ([e5350ff](https://github.com/whisper-money/whisper-money/commit/e5350ff1a6061ee3bdb9596e3b6e925618e39e3e))
* **support:** add support link with community-first help modal ([#542](https://github.com/whisper-money/whisper-money/issues/542)) ([4a891a5](https://github.com/whisper-money/whisper-money/commit/4a891a5906d57f17bbf0aeeec957656e7e67f937))
* **transactions:** default balance toggle on and apply it server-side ([#566](https://github.com/whisper-money/whisper-money/issues/566)) ([b76a0de](https://github.com/whisper-money/whisper-money/commit/b76a0de0746e334835c84e7aef96140e8ef7d00f))
* **transactions:** highlight new transactions since last visit ([#609](https://github.com/whisper-money/whisper-money/issues/609)) ([884038c](https://github.com/whisper-money/whisper-money/commit/884038c13bd093ca5ec1f6a656af40453f085efc))
* **transactions:** make new-transaction marker cross-device ([#611](https://github.com/whisper-money/whisper-money/issues/611)) ([ee69c51](https://github.com/whisper-money/whisper-money/commit/ee69c51a846a944632e6c23e098014f768229310)), closes [#609](https://github.com/whisper-money/whisper-money/issues/609)
* **transactions:** refine new transaction form layout and balance toggle ([#597](https://github.com/whisper-money/whisper-money/issues/597)) ([d6ec983](https://github.com/whisper-money/whisper-money/commit/d6ec9830dff95b1fa869b57604ccc892e97113bb))
* **transactions:** release transaction analysis to all users ([#579](https://github.com/whisper-money/whisper-money/issues/579)) ([ae6f869](https://github.com/whisper-money/whisper-money/commit/ae6f8696118cc9d4808a8ee9270de2b25850dd70))
* **transactions:** reorder filters and switch accounts to a logo dropdown ([#598](https://github.com/whisper-money/whisper-money/issues/598)) ([d7bc4e6](https://github.com/whisper-money/whisper-money/commit/d7bc4e6707a802044b5f931c54cefca23dcbeebc))
* **transactions:** serve import dedup and account ledger from the backend ([#631](https://github.com/whisper-money/whisper-money/issues/631)) ([021cb66](https://github.com/whisper-money/whisper-money/commit/021cb6664311ec475e87b628ca7a88baf8de4907))
* **welcome:** add Francisco Montes testimonial ([#636](https://github.com/whisper-money/whisper-money/issues/636)) ([02087ab](https://github.com/whisper-money/whisper-money/commit/02087abcc7c7e9f7c210ae922c7813206db6093e))
* **welcome:** add Haru testimonial with Discord avatar ([#577](https://github.com/whisper-money/whisper-money/issues/577)) ([b0e74fa](https://github.com/whisper-money/whisper-money/commit/b0e74fac2c629078d4d88b9a2365d343571cc0e6))
### Performance Improvements
* **accounts:** defer the account ledger prop on show ([#632](https://github.com/whisper-money/whisper-money/issues/632)) ([9326d8f](https://github.com/whisper-money/whisper-money/commit/9326d8fd2fd3c9739cffa7d0f2f384fa49a623c1)), closes [#631](https://github.com/whisper-money/whisper-money/issues/631) [#631](https://github.com/whisper-money/whisper-money/issues/631) [#631](https://github.com/whisper-money/whisper-money/issues/631)
* **ai:** reduce N+1 in bulk category updates (PHP-LARAVEL-40, partial) ([#624](https://github.com/whisper-money/whisper-money/issues/624)) ([3d3f6da](https://github.com/whisper-money/whisper-money/commit/3d3f6daa77c130e5a91547d9426189a1e820cad5)), closes [#2](https://github.com/whisper-money/whisper-money/issues/2)
* **banking:** kill per-transaction dedup N+1 in bank sync (PHP-LARAVEL-3Y) ([#621](https://github.com/whisper-money/whisper-money/issues/621)) ([84bad76](https://github.com/whisper-money/whisper-money/commit/84bad76316425616899f03157fa8246144635288))
* **db:** index transactions for the daily synced-email slow query (PHP-LARAVEL-3X) ([#622](https://github.com/whisper-money/whisper-money/issues/622)) ([ad46e46](https://github.com/whisper-money/whisper-money/commit/ad46e465be3cb2d3a29726a7c4cc2f822ecf67a3))
## [0.2.5](https://github.com/whisper-money/whisper-money/compare/v0.2.4...v0.2.5) (2026-06-15)
### Bug Fixes
* **account:** block deletion while subscription or trial is active ([#531](https://github.com/whisper-money/whisper-money/issues/531)) ([2bfb569](https://github.com/whisper-money/whisper-money/commit/2bfb569a2226df44b71363e731889ab07a2a41c4)) by [@victor-falcon](https://github.com/victor-falcon)
* **analysis:** align summary card amounts to a common baseline ([#515](https://github.com/whisper-money/whisper-money/issues/515)) ([21f8f3b](https://github.com/whisper-money/whisper-money/commit/21f8f3b27719f3ff47b4769e5fdda00c323fe22d)) by [@victor-falcon](https://github.com/victor-falcon)
* **analysis:** truncate long breakdown names so amounts stay in widget ([#518](https://github.com/whisper-money/whisper-money/issues/518)) ([49acc8a](https://github.com/whisper-money/whisper-money/commit/49acc8a884617fc8a97e7b82e7dcedb5931a20b4)) by [@victor-falcon](https://github.com/victor-falcon)
* **auth:** prevent FormData crash on successful login ([#503](https://github.com/whisper-money/whisper-money/issues/503)) ([f4bbbfd](https://github.com/whisper-money/whisper-money/commit/f4bbbfd767388642b856b53814187b9c85e4ac22)) by [@victor-falcon](https://github.com/victor-falcon)
* **automation-rules:** hint amount sign for expenses vs income ([#524](https://github.com/whisper-money/whisper-money/issues/524)) ([e526f86](https://github.com/whisper-money/whisper-money/commit/e526f861b2a7f46a073273482f10111f06e44f84)) by [@victor-falcon](https://github.com/victor-falcon)
* **budgets:** make period generation idempotent ([#533](https://github.com/whisper-money/whisper-money/issues/533)) ([cd323bb](https://github.com/whisper-money/whisper-money/commit/cd323bbe529678e68bca23e84a9cdb0d78c33373)) by [@victor-falcon](https://github.com/victor-falcon)
* **cashflow:** bound trend window to prevent request timeout ([#534](https://github.com/whisper-money/whisper-money/issues/534)) ([1d4bcd5](https://github.com/whisper-money/whisper-money/commit/1d4bcd5082413071ddcc2764ab866e23ea73ab5a)) by [@victor-falcon](https://github.com/victor-falcon)
* **currency:** make rate fetching resilient to slow CDN ([#502](https://github.com/whisper-money/whisper-money/issues/502)) ([4b90bcf](https://github.com/whisper-money/whisper-money/commit/4b90bcfc96b4dd6340981d53f2e665d02872288f)) by [@victor-falcon](https://github.com/victor-falcon)
* **header:** keep mobile logo on one line, compact auth buttons ([#512](https://github.com/whisper-money/whisper-money/issues/512)) ([1530544](https://github.com/whisper-money/whisper-money/commit/1530544b8b3322f90829991df3d41d06e43e54a8)) by [@victor-falcon](https://github.com/victor-falcon)
* **import:** honor selected date format for CSV imports ([#494](https://github.com/whisper-money/whisper-money/issues/494)) ([744d874](https://github.com/whisper-money/whisper-money/commit/744d874464b803b4f9a187347cb833c9440a62d1)) by [@victor-falcon](https://github.com/victor-falcon)
* **layout:** keep bottom padding while floating nav is visible ([#537](https://github.com/whisper-money/whisper-money/issues/537)) ([6671d89](https://github.com/whisper-money/whisper-money/commit/6671d89ea10638983a5ba10a718ea16dde982697)) by [@victor-falcon](https://github.com/victor-falcon)
* **perf:** batch feature flag resolution in shared Inertia data ([#500](https://github.com/whisper-money/whisper-money/issues/500)) ([cb728ce](https://github.com/whisper-money/whisper-money/commit/cb728ce176fbac50984c183abae8af19acf1c8e7)) by [@victor-falcon](https://github.com/victor-falcon), closes [#496](https://github.com/whisper-money/whisper-money/issues/496)
* **sentry:** only report errors in production ([#467](https://github.com/whisper-money/whisper-money/issues/467)) ([d68fee6](https://github.com/whisper-money/whisper-money/commit/d68fee6c2dd54f923cb0a57c1dd483d5fa4e1ed5)) by [@victor-falcon](https://github.com/victor-falcon)
* **transactions:** combine category and label filters with OR ([#495](https://github.com/whisper-money/whisper-money/issues/495)) ([af87ac7](https://github.com/whisper-money/whisper-money/commit/af87ac75600060939b7c27e99548bace462a0d73)) by [@victor-falcon](https://github.com/victor-falcon)
* **transactions:** improve mobile analysis button affordance ([#520](https://github.com/whisper-money/whisper-money/issues/520)) ([3223824](https://github.com/whisper-money/whisper-money/commit/3223824edb03d3f5657af55dc8b01a8e79259f06)) by [@victor-falcon](https://github.com/victor-falcon)
* **transactions:** prevent crash when sorting by nullable column ([#501](https://github.com/whisper-money/whisper-money/issues/501)) ([a69c602](https://github.com/whisper-money/whisper-money/commit/a69c602366c5a2c8e18ad106827fc30693ba8471)) by [@victor-falcon](https://github.com/victor-falcon)
* **transactions:** tidy filter bar into two balanced rows ([#510](https://github.com/whisper-money/whisper-money/issues/510)) ([6486908](https://github.com/whisper-money/whisper-money/commit/6486908716e563b3b8970b9ec7b73989d7ee72be)) by [@victor-falcon](https://github.com/victor-falcon)
* verify email via signed link without requiring login ([#490](https://github.com/whisper-money/whisper-money/issues/490)) ([14b7955](https://github.com/whisper-money/whisper-money/commit/14b79557d79dd1c5c5876b6c6b1a480baedd536e)) by [@victor-falcon](https://github.com/victor-falcon)
### Features
* add catch-all budgets ([#527](https://github.com/whisper-money/whisper-money/issues/527)) ([dbec1c4](https://github.com/whisper-money/whisper-money/commit/dbec1c4c134a257b95c3c1402590a6727026a4d4)) by [@tonigruni](https://github.com/tonigruni)
* **ai:** add weekly AI-suggestions cohort report ([#530](https://github.com/whisper-money/whisper-money/issues/530)) ([906e3cc](https://github.com/whisper-money/whisper-money/commit/906e3cc2b466428e7efa4c4c66cb56a068475451)) by [@victor-falcon](https://github.com/victor-falcon)
* **ai:** auto-categorize transactions with AI (behind flag) ([#535](https://github.com/whisper-money/whisper-money/issues/535)) ([8013a0b](https://github.com/whisper-money/whisper-money/commit/8013a0b6f2d53c6df64bcf6019592dd1f8e477d1)) by [@victor-falcon](https://github.com/victor-falcon)
* **ai:** defer per-transaction categorization until onboarding completes ([#536](https://github.com/whisper-money/whisper-money/issues/536)) ([e065d4a](https://github.com/whisper-money/whisper-money/commit/e065d4ab65f603f5396cafcf4634e544c2eead2f)) by [@victor-falcon](https://github.com/victor-falcon)
* **ai:** share AI sparkle icon and mark AI-generated rules ([#538](https://github.com/whisper-money/whisper-money/issues/538)) ([7dde67c](https://github.com/whisper-money/whisper-money/commit/7dde67c606fa0f8603f7603c0a6769df6755028c)) by [@victor-falcon](https://github.com/victor-falcon)
* **ai:** suggest automation rules during onboarding ([#523](https://github.com/whisper-money/whisper-money/issues/523)) ([8056ede](https://github.com/whisper-money/whisper-money/commit/8056ede63644a4fac2afa88fcc9d89d7fb3bcea1)) by [@victor-falcon](https://github.com/victor-falcon)
* **analysis:** break down spending by sub-category ([#508](https://github.com/whisper-money/whisper-money/issues/508)) ([c944a2c](https://github.com/whisper-money/whisper-money/commit/c944a2c37ecfab3c483a6c0ffa3596bdd8b73f01)) by [@victor-falcon](https://github.com/victor-falcon)
* **analysis:** per-category 12-month spending drawer ([#519](https://github.com/whisper-money/whisper-money/issues/519)) ([9c3c4d5](https://github.com/whisper-money/whisper-money/commit/9c3c4d573ee8a04155d56da39bc213b052a138a4)) by [@victor-falcon](https://github.com/victor-falcon)
* **analysis:** project-aware transaction analysis ([#513](https://github.com/whisper-money/whisper-money/issues/513)) ([7cc49a5](https://github.com/whisper-money/whisper-money/commit/7cc49a53689d95855ca254a4df8ebd6fbad5720c)) by [@victor-falcon](https://github.com/victor-falcon)
* **analysis:** shared bar-list breakdowns in transaction drawer ([#517](https://github.com/whisper-money/whisper-money/issues/517)) ([bcd025f](https://github.com/whisper-money/whisper-money/commit/bcd025f1b16490baf871718b24db71997895e07e)) by [@victor-falcon](https://github.com/victor-falcon)
* **auth:** show/hide toggle on password fields ([#499](https://github.com/whisper-money/whisper-money/issues/499)) ([58254fc](https://github.com/whisper-money/whisper-money/commit/58254fcedeb76ff99b88f5b5fafe00f807510f91)) by [@victor-falcon](https://github.com/victor-falcon)
* **banking:** add command to disconnect connections by id ([#497](https://github.com/whisper-money/whisper-money/issues/497)) ([9192947](https://github.com/whisper-money/whisper-money/commit/91929477ab94cd721bc8b6c3a7c9a28dcdfb31cf)) by [@victor-falcon](https://github.com/victor-falcon)
* **cashflow:** enlarge and color-code net cashflow and saved cards ([#505](https://github.com/whisper-money/whisper-money/issues/505)) ([346e21a](https://github.com/whisper-money/whisper-money/commit/346e21ad4eec3b9dd7369abd6503b66fd7262c92)) by [@victor-falcon](https://github.com/victor-falcon)
* **console:** add agent:db command for querying local and prod DB ([#522](https://github.com/whisper-money/whisper-money/issues/522)) ([d2a4412](https://github.com/whisper-money/whisper-money/commit/d2a44121189624ab5e5c8e1ac1baac4ffd17ec21)) by [@victor-falcon](https://github.com/victor-falcon)
* **currencies:** add Colombian and Dominican peso ([#471](https://github.com/whisper-money/whisper-money/issues/471)) ([e5b4933](https://github.com/whisper-money/whisper-money/commit/e5b493329a6b5b8e6fdf04f873e591546c42441d)) by [@victor-falcon](https://github.com/victor-falcon)
* **currency:** add NZD (New Zealand Dollar) ([#504](https://github.com/whisper-money/whisper-money/issues/504)) ([899ea6a](https://github.com/whisper-money/whisper-money/commit/899ea6a939787e567a41566aeb6fbeee6885600d)) by [@victor-falcon](https://github.com/victor-falcon)
* enable category tree for all users, remove flag ([#488](https://github.com/whisper-money/whisper-money/issues/488)) ([f8cc881](https://github.com/whisper-money/whisper-money/commit/f8cc8816a898514a558667a2938f6255363b2ed7)) by [@victor-falcon](https://github.com/victor-falcon)
* expand parent categories inline in breakdowns ([#486](https://github.com/whisper-money/whisper-money/issues/486)) ([53d0518](https://github.com/whisper-money/whisper-money/commit/53d051800bb1e676563bbc1038e607efc186bebd)) by [@victor-falcon](https://github.com/victor-falcon)
* expand Sankey subcategories inline ([#485](https://github.com/whisper-money/whisper-money/issues/485)) ([679c8d7](https://github.com/whisper-money/whisper-money/commit/679c8d7bff3b69a5b70c55ae90fd1d8236af99ee)) by [@victor-falcon](https://github.com/victor-falcon)
* **importer:** support YYYYMMDD date format ([#470](https://github.com/whisper-money/whisper-money/issues/470)) ([f9d1303](https://github.com/whisper-money/whisper-money/commit/f9d1303d986aa2cee9ee6dbf92d6a3fb708f8f05)) by [@victor-falcon](https://github.com/victor-falcon)
* **landing:** add go now button to redirect modal ([#506](https://github.com/whisper-money/whisper-money/issues/506)) ([c53c40c](https://github.com/whisper-money/whisper-money/commit/c53c40cf0b54a94c163f28ebd504dd720ff03339)) by [@victor-falcon](https://github.com/victor-falcon)
* **landing:** real testimonials with gravatar/facehash avatars ([#493](https://github.com/whisper-money/whisper-money/issues/493)) ([300188f](https://github.com/whisper-money/whisper-money/commit/300188f135a598a4ae2a5bb6f119af1d13cae7eb)) by [@victor-falcon](https://github.com/victor-falcon)
* **open-banking:** finalize bank connection without a session via state token ([#498](https://github.com/whisper-money/whisper-money/issues/498)) ([a7dde5f](https://github.com/whisper-money/whisper-money/commit/a7dde5fbc5e1a09d55d64ff101e5ea4061335fd0)) by [@victor-falcon](https://github.com/victor-falcon)
* optionally update manual account balance on transaction delete ([#491](https://github.com/whisper-money/whisper-money/issues/491)) ([45e25e0](https://github.com/whisper-money/whisper-money/commit/45e25e018de377622197ae0c3c39c9cab6053220)) by [@victor-falcon](https://github.com/victor-falcon)
* parent/child category tree ([#474](https://github.com/whisper-money/whisper-money/issues/474)) ([1cc1056](https://github.com/whisper-money/whisper-money/commit/1cc10566a36562fa37ac7ecb9592da03284f5f51)) by [@victor-falcon](https://github.com/victor-falcon)
* **settings:** let users disable bank transactions email ([#472](https://github.com/whisper-money/whisper-money/issues/472)) ([e178f1b](https://github.com/whisper-money/whisper-money/commit/e178f1b1bdb57a778ae2124e651078d1904f71c4)) by [@victor-falcon](https://github.com/victor-falcon)
* single-open Sankey expand, fit overflowing children ([#487](https://github.com/whisper-money/whisper-money/issues/487)) ([721cbef](https://github.com/whisper-money/whisper-money/commit/721cbef02464acb40ab1428c769b65a042b4d96a)) by [@victor-falcon](https://github.com/victor-falcon)
* **transactions:** filtered analysis dashboard ([#507](https://github.com/whisper-money/whisper-money/issues/507)) ([8375fd4](https://github.com/whisper-money/whisper-money/commit/8375fd490ecc473bbcb7450830ba5b55e4b6bb26)) by [@victor-falcon](https://github.com/victor-falcon)
* **transactions:** save and reuse transaction filters ([#496](https://github.com/whisper-money/whisper-money/issues/496)) ([8df44c2](https://github.com/whisper-money/whisper-money/commit/8df44c2ef492abe4a9f5d385c9eb5ca0835c7675)) by [@victor-falcon](https://github.com/victor-falcon)
* **users:** track last login and last active timestamps ([#516](https://github.com/whisper-money/whisper-money/issues/516)) ([fcf2d3d](https://github.com/whisper-money/whisper-money/commit/fcf2d3d1ad5f4f85542bd92f7bdaf23ec26c8c50)) by [@victor-falcon](https://github.com/victor-falcon)
* **welcome:** add Albert G. testimonial ([#529](https://github.com/whisper-money/whisper-money/issues/529)) ([0f48ffb](https://github.com/whisper-money/whisper-money/commit/0f48ffbbeffe12321d434c9e4714d09eb2ce9b02)) by [@victor-falcon](https://github.com/victor-falcon)
## [0.2.4](https://github.com/whisper-money/whisper-money/compare/v0.2.3...v0.2.4) (2026-06-01)
### Bug Fixes
* **accounts:** sync currency from first account ([#430](https://github.com/whisper-money/whisper-money/issues/430)) ([0d4c683](https://github.com/whisper-money/whisper-money/commit/0d4c68361afc114615323418e2cfcf380e5ac13f))
* **accounts:** translate update button in edit account modal ([#455](https://github.com/whisper-money/whisper-money/issues/455)) ([65175e1](https://github.com/whisper-money/whisper-money/commit/65175e184a900404b12a5acccabfa0d700ba57ea))
* **automation:** avoid rule preview n+1 ([#431](https://github.com/whisper-money/whisper-money/issues/431)) ([fd67cf7](https://github.com/whisper-money/whisper-money/commit/fd67cf7c72148ce6e3afa97a2cb35893f3457e4b))
* **automation:** avoid skipping rule matches ([#433](https://github.com/whisper-money/whisper-money/issues/433)) ([9772cfc](https://github.com/whisper-money/whisper-money/commit/9772cfc37c19d521ab8b1cd64a5de467ae4ae5ce))
* **banking:** handle balance-fetch timeouts and silence handled retries ([#450](https://github.com/whisper-money/whisper-money/issues/450)) ([64b78e3](https://github.com/whisper-money/whisper-money/commit/64b78e36801a34321ee2fc12ef41a2938d13c572))
* batch automation rule application ([#435](https://github.com/whisper-money/whisper-money/issues/435)) ([606093d](https://github.com/whisper-money/whisper-money/commit/606093d311f307abe76e2c5ac10c7afc6f927578))
* **budgets:** explain locked edit fields ([#437](https://github.com/whisper-money/whisper-money/issues/437)) ([235911b](https://github.com/whisper-money/whisper-money/commit/235911b960c80ba1f18dc059ced6af7b43b225da))
* **cashflow:** clarify period comparisons ([#436](https://github.com/whisper-money/whisper-money/issues/436)) ([0250fdc](https://github.com/whisper-money/whisper-money/commit/0250fdc268ab27e4058a4a5bf6efa4b699133944))
* **cashflow:** defer period label translation ([#427](https://github.com/whisper-money/whisper-money/issues/427)) ([1278a2b](https://github.com/whisper-money/whisper-money/commit/1278a2b972bf85649d0f2390277765b8a3f0bc8b))
* **cashflow:** stack mobile header controls ([#426](https://github.com/whisper-money/whisper-money/issues/426)) ([949ca11](https://github.com/whisper-money/whisper-money/commit/949ca110fa2d250e316af45d5ca42a932b919343))
* **categories:** allow recreate after delete ([#444](https://github.com/whisper-money/whisper-money/issues/444)) ([2fa822e](https://github.com/whisper-money/whisper-money/commit/2fa822e6d960f73b4d168e012a1a003a86415f85))
* **categories:** expose cashflow setting on create ([#448](https://github.com/whisper-money/whisper-money/issues/448)) ([5119528](https://github.com/whisper-money/whisper-money/commit/5119528149a1ea1686e70d081418a10ec61edd68))
* **chart:** mask stacked bar edges ([#439](https://github.com/whisper-money/whisper-money/issues/439)) ([d5d262e](https://github.com/whisper-money/whisper-money/commit/d5d262e9fd5720b21bbe1c44500ef1034290cf9a))
* **currency:** degrade gracefully when rates return 404 ([#449](https://github.com/whisper-money/whisper-money/issues/449)) ([bef657c](https://github.com/whisper-money/whisper-money/commit/bef657c2ed8a7ed7be1b7c7d232dc037d76ee39a))
* filter Safari cashback extension errors ([#447](https://github.com/whisper-money/whisper-money/issues/447)) ([0b94067](https://github.com/whisper-money/whisper-money/commit/0b9406714e158244b64cb449e6b19320305ee6b9))
* **import:** correct balance for same-day, zero and negative values ([#456](https://github.com/whisper-money/whisper-money/issues/456)) ([144d919](https://github.com/whisper-money/whisper-money/commit/144d919c0b23d8231b1095e148d4ca4a58f0e955))
* **logging:** keep laravel.log writable across container UIDs ([#451](https://github.com/whisper-money/whisper-money/issues/451)) ([741dc49](https://github.com/whisper-money/whisper-money/commit/741dc49d5371b3aa867d45c78974fe0049d6b346))
* move community link to user menu ([#442](https://github.com/whisper-money/whisper-money/issues/442)) ([4f46ae3](https://github.com/whisper-money/whisper-money/commit/4f46ae3e2d9e365a1bb45355fe2f2c310cab2bb8))
* net category refunds in cashflow ([#441](https://github.com/whisper-money/whisper-money/issues/441)) ([6caadad](https://github.com/whisper-money/whisper-money/commit/6caadad1dbc267ea5d2bb58f2ce92200146349bf))
* **register:** don't block signup on unrecognized browser timezone ([#462](https://github.com/whisper-money/whisper-money/issues/462)) ([96ee311](https://github.com/whisper-money/whisper-money/commit/96ee311299b0fb6d8234fd17cab44caa3c886488))
### Features
* **accounts:** add transaction action ([#438](https://github.com/whisper-money/whisper-money/issues/438)) ([534a147](https://github.com/whisper-money/whisper-money/commit/534a14790e777f3bd3d993fe6083fef40a9727ab))
* **accounts:** show 50 transactions per page and link to full list ([#459](https://github.com/whisper-money/whisper-money/issues/459)) ([85ea3cc](https://github.com/whisper-money/whisper-money/commit/85ea3cc71416d446fdc8858fbd8a562a02334182))
* add BRL currency support ([#453](https://github.com/whisper-money/whisper-money/issues/453)) ([4dec0ab](https://github.com/whisper-money/whisper-money/commit/4dec0ab7ca14e3100ee2b584a22b39f8cdf8e110))
* add Discord admin feed for daily stats and Stripe events ([#458](https://github.com/whisper-money/whisper-money/issues/458)) ([0b528b7](https://github.com/whisper-money/whisper-money/commit/0b528b79025314294db53a61a07231199b2b864c)), closes [#457](https://github.com/whisper-money/whisper-money/issues/457)
* add PKR currency support ([#443](https://github.com/whisper-money/whisper-money/issues/443)) ([cfa61fd](https://github.com/whisper-money/whisper-money/commit/cfa61fd23cc9b7888755684d24ede3f6a35863cb))
* add Stripe subscription stats command ([#457](https://github.com/whisper-money/whisper-money/issues/457)) ([670a0a6](https://github.com/whisper-money/whisper-money/commit/670a0a65c73d19da091f95ea9a381d7dcab1b240))
* **budgets:** track multiple categories and labels per budget ([#466](https://github.com/whisper-money/whisper-money/issues/466)) ([71dd6e2](https://github.com/whisper-money/whisper-money/commit/71dd6e2b7fcf44d889641842a13e19091a47d186))
* **cashflow:** add savings and period views ([#424](https://github.com/whisper-money/whisper-money/issues/424)) ([ed737db](https://github.com/whisper-money/whisper-money/commit/ed737db7b28442ea3674290eb7eb3f15744dd5b2))
* **cashflow:** rework summary cards into net + saved/invested ([#465](https://github.com/whisper-money/whisper-money/issues/465)) ([5ce439f](https://github.com/whisper-money/whisper-money/commit/5ce439f463bfdac7ebb3aa5c0396d098999fdad6))
* **categorize:** show debtor and creditor names ([#454](https://github.com/whisper-money/whisper-money/issues/454)) ([05ee8dc](https://github.com/whisper-money/whisper-money/commit/05ee8dc44258e7c9e6dcb6e77afedeb92f3329a2))
* **currency:** add Saudi Riyal (SAR) ([#461](https://github.com/whisper-money/whisper-money/issues/461)) ([a71626a](https://github.com/whisper-money/whisper-money/commit/a71626a350a568fc0591847aba6b0faac0d484fb))
* **discord:** enrich Stripe event messages and dedupe deliveries ([#460](https://github.com/whisper-money/whisper-money/issues/460)) ([f9bf0ea](https://github.com/whisper-money/whisper-money/commit/f9bf0ea5fff32f26af8d05dc5645a302524b3a95))
* **landing:** redirect signed-in users ([#429](https://github.com/whisper-money/whisper-money/issues/429)) ([4f42de7](https://github.com/whisper-money/whisper-money/commit/4f42de74a1beda4b7032a6e2fe6d9d1eec4edd4c))
* **leads:** add user lead re-invite campaign ([#432](https://github.com/whisper-money/whisper-money/issues/432)) ([7b03d7c](https://github.com/whisper-money/whisper-money/commit/7b03d7cf230ef0cea3fe4ce6ea2215e3348910f9))
* **leads:** schedule invitation emails ([#434](https://github.com/whisper-money/whisper-money/issues/434)) ([d5d22b9](https://github.com/whisper-money/whisper-money/commit/d5d22b9d5724cfb7363ead047946ff587772d9ea))
* **posthog:** route analytics through reverse proxy ([#463](https://github.com/whisper-money/whisper-money/issues/463)) ([448bb2e](https://github.com/whisper-money/whisper-money/commit/448bb2e64af8ebb8b2a28700d793b97fba75bf6d))
* **transactions:** add counterparty fields ([#440](https://github.com/whisper-money/whisper-money/issues/440)) ([10da06e](https://github.com/whisper-money/whisper-money/commit/10da06ed849c019fcd041d5889d5864a6c26844f))
## [0.2.3](https://github.com/whisper-money/whisper-money/compare/v0.2.2...v0.2.3) (2026-05-25)
### Bug Fixes
* allow managing canceled connections ([#417](https://github.com/whisper-money/whisper-money/issues/417)) ([eaba315](https://github.com/whisper-money/whisper-money/commit/eaba3151967a942044cffbd33c0dcee8d00cb457))
* build arm64 images on native runners ([#419](https://github.com/whisper-money/whisper-money/issues/419)) ([364c72d](https://github.com/whisper-money/whisper-money/commit/364c72db72812e337a08813019bfd8d6c84ce1a3))
* preview categorizer rule application ([#421](https://github.com/whisper-money/whisper-money/issues/421)) ([faf046b](https://github.com/whisper-money/whisper-money/commit/faf046b3c4213417190504cd0b7491012e7fcffd))
* publish arm64 docker images asynchronously ([#418](https://github.com/whisper-money/whisper-money/issues/418)) ([4411601](https://github.com/whisper-money/whisper-money/commit/44116010dee35eb93b6826e4c955d0c662aa5927)), closes [#412](https://github.com/whisper-money/whisper-money/issues/412)
### Features
* keep past due subscriptions active ([#416](https://github.com/whisper-money/whisper-money/issues/416)) ([88faa5b](https://github.com/whisper-money/whisper-money/commit/88faa5beb60c8b9948ff11284609b21fbfebee30))
* **mail:** use AWS SES for email delivery ([#422](https://github.com/whisper-money/whisper-money/issues/422)) ([d8bb78e](https://github.com/whisper-money/whisper-money/commit/d8bb78e5e096938292d0a971637d077d76cf61a8))
## [0.2.2](https://github.com/whisper-money/whisper-money/compare/v0.2.0...v0.2.2) (2026-05-22)

View File

@ -15,14 +15,6 @@ composer run dev # Start full dev environment (PHP server, queue, Vite,
bun run dev # Vite dev server only
```
### Running the server for QA
When a command or skill needs the user to open the app in Chrome to review a change:
1. **First run in a worktree?** Prepare it: `./worktree.sh /path/to/main/repo` (copies `.env` + `storage/keys`, installs `bun` and `composer` deps). Skip if already set up.
2. **Start the server** if it isn't running: `composer run dev`.
3. **Get the URL** — it's dynamic per worktree, so don't guess it. On startup `composer run dev` prints and copies it to the clipboard (`✓ <URL> copied to clipboard`); read it from the `server` pane output. Ask the user to open that URL in Chrome to QA.
### Build & Quality
```bash
@ -113,16 +105,14 @@ 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
- php - 8.4.17
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
- laravel/ai (AI) - v0
- laravel/cashier (CASHIER) - v16
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v13
- laravel/framework (LARAVEL) - v12
- laravel/pennant (PENNANT) - v1
- laravel/prompts (PROMPTS) - v0
- laravel/wayfinder (WAYFINDER) - v0
- larastan/larastan (LARASTAN) - v3
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
@ -141,14 +131,12 @@ This application is a Laravel application and its main Laravel ecosystems packag
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.
- `cashier-stripe-development` — Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues.
- `pennant-development` — Use when working with Laravel Pennant the official Laravel feature flag package. Trigger whenever the query mentions Pennant by name or involves feature flags or feature toggles in a Laravel project. Tasks include defining feature flags checking whether features are active creating class based features in `app/Features` using Blade `@feature` directives scoping flags to users or teams building custom Pennant storage drivers protecting routes with feature flags testing feature flags with Pest or PHPUnit and implementing A B testing or gradual rollouts with feature flags. Do not trigger for generic Laravel configuration authorization policies authentication or non Pennant feature management systems.
- `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` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
- `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` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
- `ai-sdk-development` — TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for other AI packages used directly.
- `fortify-development` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
- `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 &lt;Link&gt;, &lt;Form&gt;, 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.
- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
## Conventions
@ -183,23 +171,19 @@ This project has domain-specific skills available. You MUST activate the relevan
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan Commands
## Artisan
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`, `php artisan tinker --execute "..."`).
- Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
- 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.
## Debugging
## 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.
- Use the `database-schema` tool to inspect table structure before writing migrations or models.
- To execute PHP code for debugging, run `php artisan tinker --execute "your code here"` directly.
- To read configuration values, read the config files directly or run `php artisan config:show [key]`.
- To inspect routes, run `php artisan route:list` directly.
- To check environment variables, read the `.env` file directly.
## Reading Browser Logs With the `browser-logs` Tool
@ -265,7 +249,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
- 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-laravel/v2 rules ===
# Inertia
@ -277,14 +261,14 @@ protected function isAccessible(User $user, ?string $path = null): bool
# 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 scroll, merging props, polling, prefetching, once props, flash data.
- 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 `php artisan list` and check their parameters with `php artisan [command] --help`.
- 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.
@ -298,7 +282,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
- 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
@ -335,6 +319,39 @@ protected function isAccessible(User $user, ?string $path = null): bool
- 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
@ -361,6 +378,8 @@ Wayfinder generates TypeScript functions for Laravel routes. Import from `@/acti
- 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 ===
@ -368,6 +387,14 @@ Wayfinder generates TypeScript functions for Laravel routes. Import from `@/acti
- 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

View File

@ -1,55 +0,0 @@
# Welcome to Whisper Money
## How We Use Claude
Based on Víctor Falcón's usage over the last 30 days:
Work Type Breakdown:
Debug Fix ████████████████████ 100%
Top Skills & Commands:
/sentry-cli ████████████████████ 1x/month
Top MCP Servers:
_None used in this window_
## Your Setup Checklist
### Codebases
- [ ] whisper-money — https://github.com/whisper-money/whisper-money
### MCP Servers to Activate
- [ ] _None required yet_ — the team hasn't leaned on MCP servers in this window. Sentry access is via the `/sentry-cli` skill (see below).
### Skills to Know About
- [ ] `/sentry-cli` — drives Sentry from the command line (view issues, events, projects, orgs, make API calls). The team uses it when triaging and fixing flagged errors before opening a PR.
## Team Tips
_TODO_
## Get Started
_TODO_
<!-- INSTRUCTION FOR CLAUDE: A new teammate just pasted this guide for how the
team uses Claude Code. You're their onboarding buddy — warm, conversational,
not lecture-y.
Open with a warm welcome — include the team name from the title. Then: "Your
teammate uses Claude Code for [list all the work types]. Let's get you started."
Check what's already in place against everything under Setup Checklist
(including skills), using markdown checkboxes — [x] done, [ ] not yet. Lead
with what they already have. One sentence per item, all in one message.
Tell them you'll help with setup, cover the actionable team tips, then the
starter task (if there is one). Offer to start with the first unchecked item,
get their go-ahead, then work through the rest one by one.
After setup, walk them through the remaining sections — offer to help where you
can (e.g. link to channels), and just surface the purely informational bits.
Don't invent sections or summaries that aren't in the guide. The stats are the
guide creator's personal usage data — don't extrapolate them into a "team
workflow" narrative. -->

View File

@ -22,7 +22,7 @@ Whisper Money is a privacy-first personal finance application that helps you tra
> 🎮 **Try the Demo:** Experience Whisper Money with our [demo account](https://whisper.money/login?demo=1) - no registration required!
> 💬 **Join our Community:** Whether you're a user looking for help or a developer wanting to contribute, we'd love to have you in our [Discord server](https://discord.gg/m8hUhx6D9D)! Share feedback, ask questions, discuss new features, or just hang out with fellow privacy enthusiasts.
> 💬 **Join our Community:** Whether you're a user looking for help or a developer wanting to contribute, we'd love to have you in our [Discord server](https://discord.gg/2WZmDW9QZ8)! Share feedback, ask questions, discuss new features, or just hang out with fellow privacy enthusiasts.
## Features
@ -171,7 +171,7 @@ The template includes:
## Star History
[![Star History Chart](https://api.star-history.com/chart?repos=whisper-money/whisper-money&type=date&legend=top-left&sealed_token=aCH1_aXcn3SWzmvWphdcfCvsm_6KewnWP3tJmVYtXio1ulzirYZDej6gnPuguhtXgvs-urR1q_t6r4-5PWBz8ecaY2G18_a75NOMb5iJVO91OGyBtst20bXRD_tv2p7dpvTDbv3HybWdqnve-rp14PvHDqxoZR-47g4IZusfNCz-5O8Gqv0TItLJBkaA)](https://www.star-history.com/?type=date&legend=top-left&repos=whisper-money%2Fwhisper-money)
[![Star History Chart](https://api.star-history.com/svg?repos=whisper-money/whisper-money&type=date&legend=top-left)](https://www.star-history.com/#whisper-money/whisper-money&type=date&legend=top-left)
## License

View File

@ -1,49 +0,0 @@
<?php
namespace App\Actions\Ai;
use App\Jobs\CategorizeUncategorizedTransactionsJob;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\AiCategorizationGate;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class StartCategorizationBackfill
{
public function __construct(private readonly AiCategorizationGate $gate) {}
/**
* Dispatch a categorization backfill when the user is eligible and has
* something to categorize, seeding the progress cache the client polls.
*
* @return array{job_id: string, total: int}|null
*/
public function handle(User $user): ?array
{
if (! $this->gate->allows($user)) {
return null;
}
$total = Transaction::query()
->where('user_id', $user->id)
->pendingAiCategorization()
->count();
if ($total === 0) {
return null;
}
$jobId = (string) Str::uuid();
Cache::put(
CategorizeUncategorizedTransactionsJob::cacheKeyForJobId($user->id, $jobId),
['status' => 'pending', 'processed' => 0, 'total' => $total, 'applied' => 0],
now()->addHour(),
);
CategorizeUncategorizedTransactionsJob::dispatch($user, $jobId);
return ['job_id' => $jobId, 'total' => $total];
}
}

View File

@ -3,74 +3,49 @@
namespace App\Actions;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Models\Category;
use App\Models\Space;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class CreateDefaultCategories
{
/**
* Create default categories for a newly registered user, nesting child
* categories under their configured parent. Categories live in a space, so
* seeding targets the given space (defaulting to the user's active one).
* Create default categories for a newly registered user.
*/
public function handle(User $user, ?Space $space = null): void
public function handle(User $user): void
{
$space ??= $user->activeSpace();
$locale = $user->locale ?? app()->getLocale();
$defaultCategories = self::getDefaultCategories($locale);
$existingCategories = $space->categories()
$existingCategoryNames = $user->categories()
->whereIn('name', array_column($defaultCategories, 'name'))
->pluck('id', 'name');
->pluck('name')
->all();
$now = now();
$pending = collect($defaultCategories)
->reject(fn (array $category): bool => $existingCategories->has($category['name']))
$categories = collect($defaultCategories)
->reject(fn (array $category): bool => in_array($category['name'], $existingCategoryNames, true))
->map(fn (array $category): array => [
...$category,
'cashflow_direction' => $category['cashflow_direction'] ?? CategoryCashflowDirection::Hidden->value,
'id' => (string) Str::uuid(),
'user_id' => $user->id,
'space_id' => $space->id,
'created_at' => $now,
'updated_at' => $now,
]);
])
->all();
if ($pending->isEmpty()) {
if ($categories === []) {
return;
}
$idsByName = $existingCategories->merge($pending->pluck('id', 'name'));
[$children, $roots] = $pending->partition(fn (array $category): bool => isset($category['parent']));
if ($roots->isNotEmpty()) {
Category::query()->insert(
$roots->map(fn (array $category): array => Arr::except($category, 'parent'))->values()->all()
);
}
if ($children->isNotEmpty()) {
Category::query()->insert(
$children
->map(fn (array $category): array => [
...Arr::except($category, 'parent'),
'parent_id' => $idsByName[$category['parent']] ?? null,
])
->values()
->all()
);
}
Category::query()->insert($categories);
}
/**
* Get the default categories configuration for a given locale.
*
* @return array<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string, parent?: string}>
* @return array<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string}>
*/
public static function getDefaultCategories(string $locale = 'en'): array
{
@ -82,10 +57,6 @@ class CreateDefaultCategories
return array_map(function (array $category) use ($translations) {
$category['name'] = $translations[$category['name']] ?? $category['name'];
if (isset($category['parent'])) {
$category['parent'] = $translations[$category['parent']] ?? $category['parent'];
}
return $category;
}, $categories);
}
@ -94,10 +65,9 @@ class CreateDefaultCategories
}
/**
* Get the base (English) categories configuration. A `parent` entry nests
* the category under the default category with that (English) name.
* Get the base (English) categories configuration.
*
* @return array<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string, parent?: string}>
* @return array<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string}>
*/
private static function getBaseCategories(): array
{
@ -110,35 +80,30 @@ class CreateDefaultCategories
],
[
'name' => 'Cafes, restaurants, bars',
'parent' => 'Food',
'icon' => 'Wine',
'color' => 'red',
'type' => 'expense',
],
[
'name' => 'Groceries',
'parent' => 'Food',
'icon' => 'ShoppingBasket',
'color' => 'red',
'type' => 'expense',
],
[
'name' => 'Tobacco and alcohol',
'parent' => 'Food',
'icon' => 'Cigarette',
'color' => 'red',
'type' => 'expense',
],
[
'name' => 'Other groceries',
'parent' => 'Food',
'icon' => 'ShoppingBasket',
'color' => 'red',
'type' => 'expense',
],
[
'name' => 'Food delivery',
'parent' => 'Food',
'icon' => 'Pizza',
'color' => 'red',
'type' => 'expense',
@ -151,49 +116,42 @@ class CreateDefaultCategories
],
[
'name' => 'Electricity',
'parent' => 'Utility services',
'icon' => 'Bolt',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Natural gas',
'parent' => 'Utility services',
'icon' => 'Flame',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Rent and maintanence',
'parent' => 'Utility services',
'icon' => 'Wrench',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Telephone, internet, TV, computer',
'parent' => 'Utility services',
'icon' => 'Wifi',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Water',
'parent' => 'Utility services',
'icon' => 'Droplets',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Other utility expenses',
'parent' => 'Utility services',
'icon' => 'Receipt',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Household goods',
'parent' => 'Utility services',
'icon' => 'Home',
'color' => 'orange',
'type' => 'expense',
@ -206,28 +164,24 @@ class CreateDefaultCategories
],
[
'name' => 'Parking',
'parent' => 'Transportation',
'icon' => 'ParkingMeter',
'color' => 'amber',
'type' => 'expense',
],
[
'name' => 'Fuel',
'parent' => 'Transportation',
'icon' => 'Fuel',
'color' => 'amber',
'type' => 'expense',
],
[
'name' => 'Transportation expenses',
'parent' => 'Transportation',
'icon' => 'Ticket',
'color' => 'amber',
'type' => 'expense',
],
[
'name' => 'Vehicle purchase, maintenance',
'parent' => 'Transportation',
'icon' => 'Car',
'color' => 'amber',
'type' => 'expense',
@ -246,42 +200,36 @@ class CreateDefaultCategories
],
[
'name' => 'Gifts',
'parent' => 'Leisure activities, traveling',
'icon' => 'Gift',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Books, newspapers, magazines',
'parent' => 'Leisure activities, traveling',
'icon' => 'BookOpen',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Accommodation, travel expenses',
'parent' => 'Leisure activities, traveling',
'icon' => 'Hotel',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Sport and sports goods',
'parent' => 'Leisure activities, traveling',
'icon' => 'Dumbbell',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Theatre, music, cinema',
'parent' => 'Leisure activities, traveling',
'icon' => 'Clapperboard',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Hobbies and other leisure time activites',
'parent' => 'Leisure activities, traveling',
'icon' => 'Puzzle',
'color' => 'violet',
'type' => 'expense',
@ -294,21 +242,18 @@ class CreateDefaultCategories
],
[
'name' => 'Education and courses',
'parent' => 'Education, health and beauty',
'icon' => 'GraduationCap',
'color' => 'rose',
'type' => 'expense',
],
[
'name' => 'Beauty, cosmetics',
'parent' => 'Education, health and beauty',
'icon' => 'Sparkles',
'color' => 'rose',
'type' => 'expense',
],
[
'name' => 'Health and pharmaceuticals',
'parent' => 'Education, health and beauty',
'icon' => 'HeartPulse',
'color' => 'rose',
'type' => 'expense',
@ -321,7 +266,6 @@ class CreateDefaultCategories
],
[
'name' => 'Online services',
'parent' => 'Online transactions',
'icon' => 'Server',
'color' => 'fuchsia',
'type' => 'expense',
@ -336,22 +280,21 @@ class CreateDefaultCategories
'name' => 'Investments',
'icon' => 'LineChart',
'color' => 'lime',
'type' => CategoryType::Investment->value,
'type' => 'transfer',
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
],
[
'name' => 'Savings',
'icon' => 'PiggyBank',
'color' => 'lime',
'type' => CategoryType::Savings->value,
'type' => 'transfer',
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
],
[
'name' => 'Other investments',
'parent' => 'Investments',
'icon' => 'TrendingUp',
'color' => 'lime',
'type' => CategoryType::Investment->value,
'type' => 'transfer',
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
],
[
@ -392,7 +335,6 @@ class CreateDefaultCategories
],
[
'name' => 'Lottery',
'parent' => 'Gambling',
'icon' => 'TicketPercent',
'color' => 'purple',
'type' => 'expense',
@ -417,7 +359,6 @@ class CreateDefaultCategories
],
[
'name' => 'Other personal transfers',
'parent' => 'Personal transfers',
'icon' => 'ArrowLeftRight',
'color' => 'cyan',
'type' => 'transfer',

View File

@ -2,7 +2,6 @@
namespace App\Actions\Fortify;
use App\Enums\Locale;
use App\Models\User;
use App\Services\LandingAuthOverrideService;
use Illuminate\Support\Facades\Validator;
@ -36,15 +35,15 @@ class CreateNewUser implements CreatesNewUsers
Rule::unique(User::class),
],
'password' => $this->passwordRules(),
'timezone' => ['nullable', 'string', 'max:255'],
'timezone' => ['nullable', 'string', 'timezone'],
])->validate();
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
'locale' => Locale::detectFromHeader(request()->header('Accept-Language'))->value,
'timezone' => $this->normalizeTimezone($input['timezone'] ?? null),
'locale' => $this->detectLocaleFromRequest(),
'timezone' => $input['timezone'] ?? null,
]);
if (! config('mail.email_verification_enabled')) {
@ -55,19 +54,17 @@ class CreateNewUser implements CreatesNewUsers
}
/**
* Normalize a browser-detected timezone, discarding identifiers PHP does
* not recognize so a hidden auto-detected field can never block registration.
* Detect locale from Accept-Language header.
*/
protected function normalizeTimezone(?string $timezone): ?string
protected function detectLocaleFromRequest(): string
{
if ($timezone === null || $timezone === '') {
return null;
$acceptLanguage = request()->header('Accept-Language', '');
// Check if Spanish is preferred
if (preg_match('/^es(-|,|;)/i', $acceptLanguage) || $acceptLanguage === 'es') {
return 'es';
}
if (! in_array($timezone, timezone_identifiers_list(\DateTimeZone::ALL_WITH_BC), true)) {
return null;
}
return $timezone;
return 'en';
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Actions\Subscription;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\Log;
/**
* Self-service "money-back guarantee" for the pay_now experiment variant:
* refund the upfront charge, cancel the subscription immediately, and revoke
* the user's bank connections (keeping the data they already imported).
*
* Eligibility is enforced by the caller via ExperimentOffer::canSelfRefund().
* The refund is stamped before the cancel/disconnect steps run so that a
* failure in those steps can never leave a refunded-but-active subscription
* that could be refunded a second time; the cleanup is best-effort and logged.
*/
class RefundSelfServe
{
public function __construct(private DisconnectBankingConnection $disconnect) {}
public function handle(User $user): void
{
$subscription = $user->subscription('default');
if ($subscription === null || $subscription->refunded_at !== null) {
return;
}
$payment = $subscription->latestPayment();
if ($payment !== null) {
$user->refund($payment->asStripePaymentIntent()->id);
}
$subscription->forceFill(['refunded_at' => now()])->save();
try {
$subscription->cancelNow();
$user->bankingConnections()->get()->each(function (BankingConnection $connection): void {
$this->disconnect->handle($connection, deleteAccounts: false);
});
} catch (\Throwable $exception) {
Log::error('Self-serve refund issued but post-refund cleanup failed', [
'user_id' => $user->getKey(),
'subscription_id' => $subscription->getKey(),
'error' => $exception->getMessage(),
]);
}
}
}

View File

@ -1,76 +0,0 @@
<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Stringable;
/**
* Maps pre-aggregated transaction groups to categorization rules. The agent
* only ever sees merchant/description signals and the user's own category list
* never full account context and returns a strictly-typed suggestion set.
*/
class RuleSuggestionAgent implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): Stringable|string
{
return <<<'PROMPT'
You help a personal-finance app suggest automation rules that auto-categorize
a user's transactions. You are given a JSON object with:
- "transaction_groups": recurring groups of uncategorized transactions. Each has
a "field" (description, creditor_name or debtor_name), a "key", an example
"samples" list, an occurrence "count", an "avg_amount" and a "direction"
(outflow = money spent, inflow = money received).
- "categories": the user's existing categories, each with an "id", a "path"
(parent > child), a "type" and a "direction". Prefer leaf categories.
For each group that clearly belongs to a single category, return one suggestion:
- "group_key": echo the group's "key".
- "match_field": which field to match on (use the group's field).
- "match_operator": "contains" for free-text descriptions, "equals" for clean
counterparty names.
- "match_token": a short, lowercase, DISTINCTIVE substring that appears VERBATIM
in the group's samples (e.g. "mercadona", "netflix"). Never invent text that is
not present. Never use a token so generic it would match unrelated transactions
(avoid words like "compra", "payment", "card").
- "category_id": the id of the best-fitting existing category. An outflow group
must map to a spending category, an inflow group to an income category.
- If, and only if, no existing category fits, leave "category_id" empty and instead
propose "new_category_name" and "new_category_direction" (inflow or outflow).
- "confidence": 0.01.0, how sure you are.
Aim for broad coverage: return a suggestion for EVERY group you can map to a
category with reasonable confidence. Only skip a group when it is genuinely
ambiguous for example internal transfers between the user's own accounts, or a
catch-all group mixing unrelated transactions. Never invent a category you are
unsure about; instead let "confidence" honestly reflect your certainty (the app
filters out low-confidence suggestions itself).
PROMPT;
}
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'suggestions' => $schema->array()->items(
$schema->object(fn (JsonSchema $schema): array => [
'group_key' => $schema->string()->required(),
'match_field' => $schema->string()->enum(['description', 'creditor_name', 'debtor_name'])->required(),
'match_operator' => $schema->string()->enum(['contains', 'equals'])->required(),
'match_token' => $schema->string()->required(),
'category_id' => $schema->string(),
'new_category_name' => $schema->string(),
'new_category_direction' => $schema->string()->enum(['inflow', 'outflow']),
'confidence' => $schema->number()->required(),
])
)->required(),
];
}
}

View File

@ -1,66 +0,0 @@
<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Stringable;
/**
* Assigns each given transaction to one of the user's existing leaf categories.
* The agent only ever sees merchant/description signals and the user's own
* category list never full account context. Categories are referenced by a
* numeric "index" (not their id) so the model cannot hallucinate an identifier,
* and the caller maps the index back to a real category.
*/
class TransactionCategorizationAgent implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): Stringable|string
{
return <<<'PROMPT'
You categorize a personal-finance app user's bank transactions. You are given a
JSON object with:
- "transactions": the transactions to categorize. Each has a "ref" (echo it back
verbatim), a "text" (the bank description), an "amount", a "direction"
(outflow = money spent, inflow = money received) and optional "creditor_name"
/ "debtor_name" counterparties.
- "categories": the user's existing LEAF categories. Each has an "index", a "path"
(parent > child), a "type" and a "direction". You may ONLY choose from these.
For each transaction you can confidently place, return one result:
- "ref": echo the transaction's "ref".
- "category_index": the "index" of the best-fitting category. An outflow MUST map
to a spending category, an inflow to an income category. If no category fits,
OMIT this field (do not guess).
- "confidence": 0.01.0, how sure you are of this category for THIS transaction.
- "merchant_unambiguous": true only if the counterparty/merchant reliably maps to
this ONE category for every future transaction (e.g. "Netflix" Subscriptions,
"Mercadona" Groceries). false when the right category depends on the specific
purchase (e.g. "Amazon", "PayPal", a generic bank transfer, an ATM withdrawal).
Let "confidence" honestly reflect your certainty the app filters out low-confidence
results itself. Never invent a category that is not in the provided list.
PROMPT;
}
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'results' => $schema->array()->items(
$schema->object(fn (JsonSchema $schema): array => [
'ref' => $schema->string()->required(),
'category_index' => $schema->integer(),
'confidence' => $schema->number()->required(),
'merchant_unambiguous' => $schema->boolean()->required(),
])
)->required(),
];
}
}

View File

@ -1,57 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Throwable;
class AgentDatabaseCommand extends Command
{
protected $signature = 'agent:db
{query : The SQL query to run}
{--format=json : Output format: "json" or "table"}
{--prod : Run against the production database connection}';
protected $description = 'Run a database query and return the result in the selected format';
public function handle(): int
{
$format = strtolower((string) $this->option('format'));
if (! in_array($format, ['json', 'table'], true)) {
$this->error('Invalid format. Use "json" or "table".');
return self::FAILURE;
}
$connection = $this->option('prod') ? 'prod' : config('database.default');
try {
$rows = array_map(
static fn (object $row): array => (array) $row,
DB::connection($connection)->select($this->argument('query')),
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
if ($format === 'json') {
$this->line((string) json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
if ($rows === []) {
$this->info('Empty result set.');
return self::SUCCESS;
}
$this->table(array_keys($rows[0]), $rows);
return self::SUCCESS;
}
}

View File

@ -3,7 +3,6 @@
namespace App\Console\Commands;
use App\Contracts\BankingProviderInterface;
use App\Enums\BankingProvider;
use App\Models\Account;
use App\Models\User;
use Illuminate\Console\Command;
@ -33,7 +32,7 @@ class BackfillAccountIbans extends Command
->whereNull('iban')
->whereNotNull('external_account_id')
->whereNotNull('banking_connection_id')
->whereHas('bankingConnection', fn ($q) => $q->where('provider', BankingProvider::EnableBanking));
->whereHas('bankingConnection', fn ($q) => $q->where('provider', 'enablebanking'));
if ($connectionId) {
$query->where('banking_connection_id', $connectionId);

View File

@ -1,82 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Account;
use App\Models\AutomationRule;
use App\Models\BankingConnection;
use App\Models\Budget;
use App\Models\Category;
use App\Models\Label;
use App\Models\SavedFilter;
use App\Models\Space;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class BackfillSpaces extends Command
{
protected $signature = 'spaces:backfill {--chunk=500 : Users processed per batch}';
protected $description = 'Provision a personal space for every user and stamp their existing rows with it';
/**
* Owned tables to backfill. Each row inherits the personal space of its
* user_id. Idempotent: only rows with a null space_id are touched.
*
* @var list<class-string<Model>>
*/
private array $models = [
Account::class,
BankingConnection::class,
Transaction::class,
Category::class,
Label::class,
Budget::class,
AutomationRule::class,
SavedFilter::class,
];
public function handle(): int
{
$chunk = (int) $this->option('chunk');
// Soft-deleted users are included: their accounts/transactions still
// exist and must be stamped too, so a restored account keeps its data
// and the column can eventually go NOT NULL.
$this->info('Provisioning personal spaces…');
User::withTrashed()->whereNull('current_space_id')->chunkById($chunk, function ($users): void {
foreach ($users as $user) {
$user->provisionPersonalSpace();
}
});
$this->info('Backfilling space_id on owned rows…');
Space::query()->where('personal', true)->chunkById($chunk, function ($spaces): void {
foreach ($spaces as $space) {
foreach ($this->models as $model) {
$this->stamp($model, $space->owner_id, $space->id);
}
}
});
$this->info('Done.');
return self::SUCCESS;
}
/**
* @param class-string<Model> $model
*/
private function stamp(string $model, string $ownerId, string $spaceId): void
{
// Go through the query builder rather than Eloquent so soft-deleted rows
// are stamped too (no soft-delete global scope to work around).
DB::table((new $model)->getTable())
->whereNull('space_id')
->where('user_id', $ownerId)
->update(['space_id' => $spaceId]);
}
}

View File

@ -4,7 +4,6 @@ namespace App\Console\Commands;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Mail\EnableBankingConnectionsCancelledEmail;
use App\Models\BankingConnection;
use Illuminate\Console\Command;
@ -23,7 +22,7 @@ class CancelFreeEnableBankingConnectionsCommand extends Command
$connections = BankingConnection::query()
->with(['user', 'accounts'])
->whereHas('user')
->where('provider', BankingProvider::EnableBanking)
->where('provider', 'enablebanking')
->where('status', '!=', BankingConnectionStatus::Revoked)
->where('created_at', '<=', $cutoff)
->get();

View File

@ -1,87 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Enums\RuleOrigin;
use App\Models\AutomationRule;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\AiCategorizationGate;
use App\Services\Ai\AiCategorizer;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
class CategorizeBackfillCommand extends Command
{
protected $signature = 'ai:categorize-backfill {user : User id or email}';
protected $description = 'Categorize a user\'s existing uncategorized transactions with AI (explicit, opt-in backfill)';
public function handle(AiCategorizationGate $gate, AiCategorizer $categorizer): int
{
$user = $this->resolveUser((string) $this->argument('user'));
if ($user === null) {
$this->error('User not found.');
return self::FAILURE;
}
if (! $gate->allows($user)) {
$this->warn('User is not eligible (needs the feature enabled, a pro plan and active AI consent).');
return self::FAILURE;
}
$pendingIds = Transaction::query()
->where('user_id', $user->id)
->whereNull('category_id')
->whereNull('description_iv')
->pluck('id');
if ($pendingIds->isEmpty()) {
$this->info('No uncategorized transactions to backfill.');
return self::SUCCESS;
}
$rulesBefore = $this->aiRuleCount($user);
$applied = 0;
$batchSize = max(1, (int) config('ai_categorization.group_batch_size'));
// Chunk a fixed snapshot of ids so transactions left blank (below the
// confidence bar) are never re-processed on a later iteration.
$this->withProgressBar(
$pendingIds->chunk($batchSize),
function ($chunkIds) use ($user, $categorizer, &$applied): void {
$chunk = Transaction::query()->whereIn('id', $chunkIds->all())->get();
$outcomes = $categorizer->run($user, new Collection($chunk->all()));
$applied += count(array_filter($outcomes, fn ($outcome): bool => $outcome->applied));
},
);
$this->newLine(2);
$this->components->info(sprintf(
'Backfill complete: %d transaction(s) categorized, %d AI rule(s) learned.',
$applied,
$this->aiRuleCount($user) - $rulesBefore,
));
return self::SUCCESS;
}
private function aiRuleCount(User $user): int
{
return AutomationRule::query()
->where('user_id', $user->id)
->origin(RuleOrigin::Ai)
->count();
}
private function resolveUser(string $identifier): ?User
{
return User::query()->where('email', $identifier)->first()
?? User::query()->find($identifier);
}
}

View File

@ -1,43 +0,0 @@
<?php
namespace App\Console\Commands\Concerns;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
trait FindsUsersWithLegacyEncryption
{
/**
* Users who still have at least one client-side encrypted transaction or account
* (a non-null `*_iv` column is the source of truth, not the stale `accounts.encrypted` flag).
*
* Subscriptions are eager-loaded so callers can filter with {@see excludeBilledUsers()}
* without an N+1 query.
*
* @return Builder<User>
*/
protected function usersWithLegacyEncryption(): Builder
{
return User::query()->with('subscriptions')->where(function (Builder $query): void {
$query->whereHas('transactions', function (Builder $transactions): void {
$transactions->whereNotNull('description_iv')
->orWhereNotNull('notes_iv');
})->orWhereHas('accounts', function (Builder $accounts): void {
$accounts->whereNotNull('name_iv');
});
});
}
/**
* Drop users who are still being billed. These accounts must never be emailed a
* deletion warning nor deleted while a subscription or trial is active.
*
* @param Collection<int, User> $users
* @return Collection<int, User>
*/
protected function excludeBilledUsers(Collection $users): Collection
{
return $users->reject(fn (User $user): bool => $user->hasActiveSubscriptionOrTrial())->values();
}
}

View File

@ -1,45 +0,0 @@
<?php
namespace App\Console\Commands\Concerns;
use Illuminate\Console\Command;
/**
* Render Discord report embeds as plain console output, for the --no-discord
* flag on the stats:* report commands.
*
* @mixin Command
*/
trait RendersReportToConsole
{
/**
* @param array<int, array<string, mixed>> $embeds
*/
protected function printEmbeds(array $embeds): void
{
foreach ($embeds as $embed) {
if (! empty($embed['title'])) {
$this->newLine();
$this->line('<options=bold>'.$this->plainText($embed['title']).'</>');
}
if (! empty($embed['description'])) {
$this->line($this->plainText($embed['description']));
}
foreach ($embed['fields'] ?? [] as $field) {
$this->newLine();
$this->line('<options=bold>'.$this->plainText($field['name']).'</>');
$this->line($this->plainText($field['value']));
}
}
}
/**
* Strip Discord markdown (code fences, bold) for readable console output.
*/
private function plainText(string $text): string
{
return trim(str_replace(['```', '**'], '', $text));
}
}

View File

@ -1,87 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Concerns\FindsUsersWithLegacyEncryption;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class DeleteEncryptedDataAccountsCommand extends Command
{
use FindsUsersWithLegacyEncryption;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'encryption:delete-accounts
{--days=7 : Spare users active within the last N days}
{--dry-run : List the accounts that would be deleted without touching them}
{--force : Skip the confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Soft-delete and anonymize users who still have legacy encrypted data and did not sign in within the grace window';
/**
* Execute the console command.
*/
public function handle(): int
{
$days = (int) $this->option('days');
$cutoff = now()->subDays($days);
$users = $this->excludeBilledUsers(
$this->usersWithLegacyEncryption()
->where('email', '!=', config('app.demo.email'))
->where(function (Builder $query) use ($cutoff): void {
$query->whereNull('last_active_at')
->orWhere('last_active_at', '<', $cutoff);
})
->get()
);
if ($users->isEmpty()) {
$this->info('No accounts to delete.');
return self::SUCCESS;
}
$this->info("Found {$users->count()} account(s) with encrypted data and no activity in the last {$days} day(s).");
if ($this->option('dry-run')) {
$this->table(['Email', 'Last active'], $users->map(fn (User $user) => [
$user->email,
$user->last_active_at?->toDateTimeString() ?? 'never',
])->all());
$this->info('[dry-run] No accounts deleted.');
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->confirm("Soft-delete and anonymize {$users->count()} account(s)?", false)) {
$this->info('Cancelled.');
return self::SUCCESS;
}
$progressBar = $this->output->createProgressBar($users->count());
$progressBar->start();
foreach ($users as $user) {
$user->markAsDeleted();
$progressBar->advance();
}
$progressBar->finish();
$this->newLine();
$this->info("Deleted {$users->count()} account(s).");
return self::SUCCESS;
}
}

View File

@ -3,7 +3,6 @@
namespace App\Console\Commands;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Enums\BankingProvider;
use App\Models\User;
use Illuminate\Console\Command;
use Laravel\Cashier\Subscription;
@ -54,7 +53,7 @@ class DeleteUserCommand extends Command
$subscription = $this->activeSubscription($user);
$enableBankingConnections = $user->bankingConnections()
->with('accounts')
->where('provider', BankingProvider::EnableBanking)
->where('provider', 'enablebanking')
->get();
if ($subscription && ! $this->confirm("User '{$user->email}' has an active Stripe subscription. Cancel it before deleting the user?")) {

View File

@ -1,68 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Models\BankingConnection;
use Illuminate\Console\Command;
class DisconnectBankingConnectionsCommand extends Command
{
protected $signature = 'banking:disconnect
{ids : One or more banking connection IDs, comma-separated}
{--delete-accounts : Also delete linked accounts, transactions and balances}';
protected $description = 'Disconnect (soft-delete) banking connections and revoke their session on the provider';
public function handle(DisconnectBankingConnection $disconnectBankingConnection): int
{
$ids = collect(explode(',', (string) $this->argument('ids')))
->map(fn (string $id): string => trim($id))
->filter()
->unique()
->values();
if ($ids->isEmpty()) {
$this->error('No connection IDs provided.');
return Command::FAILURE;
}
$deleteAccounts = $this->option('delete-accounts');
$connections = BankingConnection::query()
->with('accounts')
->whereIn('id', $ids)
->get();
$missing = $ids->diff($connections->pluck('id'));
foreach ($missing as $id) {
$this->warn("Connection not found: {$id}");
}
if ($connections->isEmpty()) {
$this->error('No matching banking connections found.');
return Command::FAILURE;
}
$disconnected = 0;
foreach ($connections as $connection) {
try {
$disconnectBankingConnection->handle($connection, $deleteAccounts);
$this->info("Disconnected connection {$connection->id} ({$connection->aspsp_name}).");
$disconnected++;
} catch (\Throwable $e) {
$this->error("Failed to disconnect {$connection->id}: {$e->getMessage()}");
}
}
$this->info("Disconnected {$disconnected} of {$connections->count()} connection(s).");
return $missing->isEmpty() && $disconnected === $connections->count()
? Command::SUCCESS
: Command::FAILURE;
}
}

View File

@ -1,164 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Enums\BankingConnectionStatus;
use App\Models\BankingConnection;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
/**
* Support command for the live Enable Banking e2e script
* (tests/Browser/live/connect-bank.mjs). It seeds deterministic users, inspects the
* resulting connection, and can force a connection into the expired state so the
* reconnect flow can be exercised. Output is JSON so the script can parse it.
*
* Restricted to local/testing environments it creates passwordful users and fake
* subscriptions and must never run against production data.
*/
class E2eBankingFixtureCommand extends Command
{
protected $signature = 'e2e:banking-fixture {action : seed|inspect|expire} {email? : target user email for inspect/expire}';
protected $description = 'Seed/inspect fixtures for the live Enable Banking e2e script (local only).';
private const ONBOARDING_EMAIL = 'e2e-onboarding@example.test';
private const SETTINGS_EMAIL = 'e2e-settings@example.test';
private const PASSWORD = 'password';
public function handle(): int
{
if (! app()->environment('local', 'testing')) {
$this->error('e2e:banking-fixture is only available in local/testing environments.');
return self::FAILURE;
}
return match ($this->argument('action')) {
'seed' => $this->seed(),
'inspect' => $this->inspect(),
'expire' => $this->expire(),
default => $this->invalidAction(),
};
}
private function seed(): int
{
$onboarding = $this->resetUser(self::ONBOARDING_EMAIL, onboarded: false, pro: false);
$settings = $this->resetUser(self::SETTINGS_EMAIL, onboarded: true, pro: true);
$this->line((string) json_encode([
'onboarding' => ['email' => $onboarding->email, 'password' => self::PASSWORD],
'settings' => ['email' => $settings->email, 'password' => self::PASSWORD],
], JSON_PRETTY_PRINT));
return self::SUCCESS;
}
private function inspect(): int
{
$connection = $this->latestConnection();
if (! $connection) {
$this->line((string) json_encode(['connection' => null]));
return self::SUCCESS;
}
$accountIds = $connection->accounts()->pluck('id');
$this->line((string) json_encode([
'connection' => [
'id' => $connection->id,
'status' => $connection->status->value,
'aspsp_name' => $connection->aspsp_name,
'session_id' => $connection->session_id !== null,
'valid_until' => $connection->valid_until?->toIso8601String(),
'accounts_count' => $accountIds->count(),
'accounts_without_external_id' => $connection->accounts()->whereNull('external_account_id')->count(),
'transactions_count' => Transaction::whereIn('account_id', $accountIds)->count(),
],
], JSON_PRETTY_PRINT));
return self::SUCCESS;
}
private function expire(): int
{
$connection = $this->latestConnection();
if (! $connection) {
$this->error('No connection found for '.$this->argument('email'));
return self::FAILURE;
}
$connection->update([
'status' => BankingConnectionStatus::Expired,
'valid_until' => now()->subDay(),
]);
$this->line((string) json_encode(['expired' => $connection->id]));
return self::SUCCESS;
}
private function resetUser(string $email, bool $onboarded, bool $pro): User
{
// Reuse the user across runs (the model soft-deletes, so recreating the same
// email would collide). Clear everything the flows produce so each run starts
// from a clean slate.
$user = User::withTrashed()->firstWhere('email', $email)
?? User::factory()->create(['email' => $email]);
$user->accounts()->forceDelete();
$user->bankingConnections()->forceDelete();
$user->subscriptions()->delete();
$user->restore();
$user->forceFill([
'email_verified_at' => now(),
'password' => Hash::make(self::PASSWORD),
'onboarded_at' => $onboarded ? now() : null,
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
])->save();
if ($pro) {
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_e2e_'.$user->id,
'stripe_status' => 'active',
'stripe_price' => 'price_e2e',
'quantity' => 1,
]);
}
return $user;
}
private function latestConnection(): ?BankingConnection
{
$email = $this->argument('email');
$user = User::where('email', $email)->first();
if (! $user) {
return null;
}
return $user->bankingConnections()->latest()->first();
}
private function invalidAction(): int
{
$this->error('Unknown action. Use one of: seed, inspect, expire.');
return self::FAILURE;
}
}

View File

@ -11,7 +11,7 @@ class FeatureEnableCommand extends Command
{
use ResolvesFeatures;
protected $signature = 'feature:enable {feature : The feature name (class name or string-based feature)} {target : User email, "all" for everyone, or a percentage like "25%" for a random rollout}';
protected $signature = 'feature:enable {feature : The feature name (class name or string-based feature)} {target : User email or "all" for everyone}';
protected $description = 'Enable a feature for a specific user or all users';
@ -36,10 +36,6 @@ class FeatureEnableCommand extends Command
return self::SUCCESS;
}
if (preg_match('/^(\d{1,3})%$/', $target, $matches)) {
return $this->enableForPercentage($featureClass, $featureName, (int) $matches[1]);
}
$user = User::where('email', $target)->first();
if (! $user) {
@ -53,23 +49,4 @@ class FeatureEnableCommand extends Command
return self::SUCCESS;
}
private function enableForPercentage(string $featureClass, string $featureName, int $percentage): int
{
if ($percentage < 1 || $percentage > 100) {
$this->error('Percentage must be between 1 and 100.');
return self::FAILURE;
}
$count = (int) ceil(User::count() * $percentage / 100);
User::inRandomOrder()
->take($count)
->each(fn (User $user) => Feature::for($user)->activate($featureClass));
$this->info("Feature '{$featureName}' enabled for {$count} users (~{$percentage}% of current users).");
return self::SUCCESS;
}
}

View File

@ -1,99 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Concerns\FindsUsersWithLegacyEncryption;
use App\Jobs\SendUpdateEmailJob;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
class NotifyEncryptedDataRemovalCommand extends Command
{
use FindsUsersWithLegacyEncryption;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'encryption:notify-removal
{--dry-run : List affected users without sending anything}
{--force : Skip the confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Warn users with legacy encrypted data that their account will be deleted unless they sign in within 7 days';
private const VIEW = 'encrypted-data-removal';
private const IDENTIFIER = 'encrypted-data-removal';
private const SUBJECT = 'Action required: sign in to keep your Whisper Money account';
/**
* Execute the console command.
*/
public function handle(): int
{
$encryptedUsers = $this->usersWithLegacyEncryption()->get();
// Always report the total scope of legacy encrypted data (soft-deleted users are
// excluded by the model's default scope). This is the signal for deciding whether
// the browser-side encryption code can finally be removed, independent of who we
// actually email below.
$this->info("{$encryptedUsers->count()} non-deleted user(s) still have encrypted data.");
$users = $this->excludeBilledUsers($encryptedUsers);
if ($users->isEmpty()) {
$this->info('No users to warn.');
return self::SUCCESS;
}
if ($this->option('dry-run')) {
$this->renderTable($users);
$this->info('[dry-run] No emails sent.');
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->confirm("Send the deletion warning to {$users->count()} user(s)?", true)) {
$this->info('Cancelled.');
return self::SUCCESS;
}
$progressBar = $this->output->createProgressBar($users->count());
$progressBar->start();
foreach ($users->values() as $index => $user) {
// Spread over the 'emails' queue at 50/day to match the existing bulk-email convention.
SendUpdateEmailJob::dispatch($user, self::VIEW, self::IDENTIFIER, self::SUBJECT)
->delay(now()->addDays((int) floor($index / 50)));
$progressBar->advance();
}
$progressBar->finish();
$this->newLine();
$this->info("Queued {$users->count()} warning email(s) to the 'emails' queue (50/day).");
return self::SUCCESS;
}
/**
* @param Collection<int, User> $users
*/
private function renderTable($users): void
{
$this->table(['Email', 'Last active'], $users->map(fn (User $user) => [
$user->email,
$user->last_active_at?->toDateTimeString() ?? 'never',
])->all());
}
}

View File

@ -40,14 +40,6 @@ class ResetDemoAccountCommand extends Command
public function handle(): int
{
$demoEnabled = config('app.demo.enabled');
if (! $demoEnabled) {
$this->info('Demo account is not enabled');
return self::SUCCESS;
}
$demoEmail = config('app.demo.email');
$demoPassword = config('app.demo.password');

View File

@ -1,177 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Enums\IntegrationRequestStatus;
use App\Models\IntegrationRequest;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class ReviewIntegrationRequestsCommand extends Command
{
/**
* @var string
*/
protected $signature = 'integration-requests:review {--all : Pick any request from the full list and change its status}';
/**
* @var string
*/
protected $description = 'Review integration requests and change their status';
public function handle(): int
{
return $this->option('all') ? $this->reviewAny() : $this->reviewPending();
}
private function reviewPending(): int
{
$pending = $this->load(IntegrationRequest::query()->where('status', IntegrationRequestStatus::Pending));
if ($pending->isEmpty()) {
$this->info('No pending integration requests.');
return self::SUCCESS;
}
$this->printTable($pending);
$changed = 0;
foreach ($pending as $request) {
$decision = $this->choice(
"Review \"{$request->name}\" ({$request->url})",
['approve', 'in progress', 'reject', 'not doable', 'done', 'skip'],
'skip',
);
if ($decision !== 'skip' && $this->apply($request, $decision)) {
$changed++;
}
}
$this->info("Done. Updated {$changed} of {$pending->count()} requests.");
return self::SUCCESS;
}
private function reviewAny(): int
{
$requests = $this->load(IntegrationRequest::query());
if ($requests->isEmpty()) {
$this->info('No integration requests.');
return self::SUCCESS;
}
$this->printTable($requests, withIndex: true);
$request = $requests->get((int) $this->ask('Which request do you want to update? (number)') - 1);
if ($request === null) {
$this->error('That number is not on the list.');
return self::FAILURE;
}
$this->apply($request, $this->choice(
"New status for \"{$request->name}\"",
['approve', 'in progress', 'reject', 'not doable', 'done'],
));
$this->info("\"{$request->name}\" is now {$request->status->label()}.");
return self::SUCCESS;
}
private function apply(IntegrationRequest $request, string $decision): bool
{
$status = match ($decision) {
'approve' => IntegrationRequestStatus::Approved,
'in progress' => IntegrationRequestStatus::InProgress,
'reject' => IntegrationRequestStatus::Rejected,
'not doable' => IntegrationRequestStatus::NotDoable,
'done' => IntegrationRequestStatus::Done,
default => null,
};
if ($status === null) {
return false;
}
$request->update([
'status' => $status,
'comment' => $this->commentFor($status),
]);
return true;
}
private function commentFor(IntegrationRequestStatus $status): ?string
{
if ($status === IntegrationRequestStatus::NotDoable) {
$comment = $this->ask('Why is this integration not doable? (shown to users)');
while (blank($comment)) {
$comment = $this->ask('A comment is required to mark an integration as not doable');
}
return $comment;
}
if ($status === IntegrationRequestStatus::InProgress) {
return $this->ask('Add a comment for this request (optional, shown to users)') ?: null;
}
// Approving, rejecting, re-queuing or marking done drops any stale public comment.
return null;
}
/**
* @param Builder<IntegrationRequest> $query
* @return Collection<int, IntegrationRequest>
*/
private function load(Builder $query): Collection
{
return $query
->withCount('votes')
// Authors who deleted their account are soft-deleted, but their requests
// linger; load them trashed so the table still shows who submitted each one.
->with(['user' => fn ($user) => $user->withTrashed()->select('id', 'email')])
->orderBy('created_at')
->get();
}
/**
* @param Collection<int, IntegrationRequest> $requests
*/
private function printTable(Collection $requests, bool $withIndex = false): void
{
$headers = ['Name', 'URL', 'Status', 'Submitted by', 'Votes', 'Created'];
if ($withIndex) {
array_unshift($headers, '#');
}
$rows = $requests->values()->map(function (IntegrationRequest $request, int $index) use ($withIndex): array {
$row = [
$request->name,
$request->url,
$request->status->label(),
$request->user->email,
$request->votes_count,
$request->created_at?->format('Y-m-d') ?? '—',
];
if ($withIndex) {
array_unshift($row, $index + 1);
}
return $row;
})->all();
$this->table($headers, $rows);
}
}

View File

@ -1,128 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Concerns\RendersReportToConsole;
use App\Services\Ai\AiCohortReportCollector;
use App\Services\Discord\DiscordWebhook;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
class SendAiCohortReportCommand extends Command
{
use RendersReportToConsole;
protected $signature = 'stats:ai-cohort-report {--weeks= : Number of weekly cohorts to include} {--no-discord : Print the report to the console only, without posting to Discord}';
protected $description = 'Post the weekly AI-suggestions cohort retention/conversion report to Discord';
public function __construct(private AiCohortReportCollector $collector)
{
parent::__construct();
}
public function handle(): int
{
$weeks = $this->option('weeks') !== null ? (int) $this->option('weeks') : null;
$report = $this->collector->collect($weeks);
$embed = $this->buildEmbed($report);
if ($this->option('no-discord')) {
$this->printEmbeds([$embed]);
$this->info('Skipped Discord (--no-discord).');
return self::SUCCESS;
}
$webhookUrl = config('services.discord.ai_cohort_webhook_url')
?: config('services.discord.webhook_url');
(new DiscordWebhook($webhookUrl))->send('', [$embed]);
$this->info('AI cohort report sent to Discord.');
return self::SUCCESS;
}
/**
* @param array{releaseAt: ?CarbonImmutable, releaseWeek: ?string, weeks: list<array<string, mixed>>} $report
* @return array<string, mixed>
*/
private function buildEmbed(array $report): array
{
$lines = [sprintf('%-9s %5s %6s %6s %6s %5s', 'Week', 'Elig', 'Ret', 'Trial', 'Paid', 'AI')];
foreach ($report['weeks'] as $row) {
$flags = '';
if ($report['releaseWeek'] !== null && $row['week'] === $report['releaseWeek']) {
$flags .= ' 🚀';
}
if ($row['surge']) {
$flags .= ' ⚡';
}
$lines[] = sprintf(
'%-9s %5d %6s %6s %6s %5s%s',
$row['week'],
$row['eligible'],
$this->rateCell($row['retainedRate'], $row['retentionMature'], $row['eligible']),
$this->rateCell($row['trialRate'], $row['retentionMature'], $row['eligible']),
$this->rateCell($row['paidRate'], $row['paidMature'], $row['eligible']),
$this->aiCell($row['aiAcceptedRate'], $row['eligible']),
$flags,
);
}
$release = $report['releaseAt'] !== null
? 'First AI consent (release anchor): '.$report['releaseAt']->format('D, d M Y').' · week '.$report['releaseWeek'].' 🚀'
: 'No AI consent recorded yet — feature not live in production.';
return [
'title' => '🤖 AI Suggestions — Weekly Cohort Report',
'description' => "```\n".implode("\n", $lines)."\n```",
'color' => 0x5865F2,
'fields' => [
[
'name' => 'Release anchor',
'value' => $release,
'inline' => false,
],
[
'name' => 'Legend',
'value' => 'Elig = users with ≥50 transactions in their first 7 days · Ret = active ≥14d after signup · Trial = subscribed ≤14d · Paid = active subscription ≤30d · AI = accepted AI consent · `pend` = cohort too young to score · ⚡ = signup surge',
'inline' => false,
],
[
'name' => '⚠️ Directional only',
'value' => 'Pre/post comparison, not a randomised test. Cohorts are compared at equal age. Surge weeks (⚡, e.g. launch/YouTube) differ in acquisition channel and are not controlled — compare organic weeks like-for-like. Confidence builds over a quarter, not a single month.',
'inline' => false,
],
],
];
}
private function rateCell(?float $rate, bool $mature, int $eligible): string
{
if ($eligible === 0) {
return '—';
}
if (! $mature || $rate === null) {
return 'pend';
}
return ((int) round($rate * 100)).'%';
}
private function aiCell(?float $rate, int $eligible): string
{
if ($eligible === 0 || $rate === null) {
return '—';
}
return ((int) round($rate * 100)).'%';
}
}

View File

@ -1,57 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Jobs\Drip\SendAiConsentFollowUpEmailJob;
use App\Models\AiConsent;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
class SendAiConsentFollowUpEmailsCommand extends Command
{
protected $signature = 'email:ai-consent-follow-up';
protected $description = 'Queue the AI consent follow-up email for users who opted into AI two days ago, outside of onboarding';
/**
* Consent given more than this many days after sign-up is treated as a
* deliberate, post-onboarding opt-in (the audience for this email).
*/
private const ONBOARDING_GRACE_DAYS = 3;
public function handle(): int
{
if (! config('mail.drip_emails_enabled')) {
$this->info('Drip emails are disabled. Nothing to do.');
return self::SUCCESS;
}
$queued = 0;
AiConsent::query()
->active()
->whereDate('accepted_at', today()->subDays(2))
->with('user')
->chunkById(100, function (Collection $consents) use (&$queued): void {
foreach ($consents as $consent) {
$user = $consent->user;
if ($user === null) {
continue;
}
if ($consent->accepted_at->lessThanOrEqualTo($user->created_at->copy()->addDays(self::ONBOARDING_GRACE_DAYS))) {
continue;
}
SendAiConsentFollowUpEmailJob::dispatch($user);
$queued++;
}
});
$this->info("Queued {$queued} AI consent follow-up email(s).");
return self::SUCCESS;
}
}

View File

@ -1,127 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Concerns\RendersReportToConsole;
use App\Models\User;
use App\Services\Discord\DiscordWebhook;
use App\Services\Stripe\SubscriptionStatsCollector;
use App\Support\Money;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Stripe\Exception\ApiErrorException;
class SendDailyStatsReportCommand extends Command
{
use RendersReportToConsole;
protected $signature = 'stats:daily-report {--no-discord : Print the report to the console only, without posting to Discord}';
protected $description = 'Post yesterday\'s user and Stripe subscription stats to the Discord admin channel';
private const TIMEZONE = 'Europe/Madrid';
public function __construct(
private SubscriptionStatsCollector $collector,
private DiscordWebhook $discord,
) {
parent::__construct();
}
public function handle(): int
{
try {
$stats = $this->collector->collect();
} catch (ApiErrorException $exception) {
$this->error("Stripe API error: {$exception->getMessage()}");
return self::FAILURE;
}
$yesterdayStart = Carbon::now(self::TIMEZONE)->subDay()->startOfDay();
$todayStart = Carbon::now(self::TIMEZONE)->startOfDay();
$newUsers = User::query()
->whereBetween('created_at', [$yesterdayStart->copy()->utc(), $todayStart->copy()->utc()])
->count();
$totalUsers = User::query()
->where('created_at', '<', $todayStart->copy()->utc())
->count();
$embed = $this->buildEmbed($stats, $newUsers, $totalUsers, $yesterdayStart);
if ($this->option('no-discord')) {
$this->printEmbeds([$embed]);
$this->info('Skipped Discord (--no-discord).');
return self::SUCCESS;
}
$this->discord->send('', [$embed]);
$this->info('Daily stats report sent to Discord.');
return self::SUCCESS;
}
/**
* @param array{active: array<string, array{count: int, mrr: float}>, trialing: array<string, array{count: int, mrr: float}>} $stats
* @return array<string, mixed>
*/
private function buildEmbed(array $stats, int $newUsers, int $totalUsers, Carbon $day): array
{
$fields = [
[
'name' => '👥 Users',
'value' => "New yesterday: **{$newUsers}**\nTotal: **{$totalUsers}**",
'inline' => false,
],
];
$currencies = array_unique(array_merge(array_keys($stats['active']), array_keys($stats['trialing'])));
sort($currencies);
foreach ($currencies as $currency) {
$active = $stats['active'][$currency] ?? ['count' => 0, 'mrr' => 0.0];
$trialing = $stats['trialing'][$currency] ?? ['count' => 0, 'mrr' => 0.0];
$currentMrr = $active['mrr'];
$projectedMrr = $active['mrr'] + $trialing['mrr'];
$fields[] = [
'name' => '💳 '.strtoupper($currency),
'value' => implode("\n", [
"Active: **{$active['count']}** ({$this->money($currentMrr, $currency)} MRR)",
"Trialing: **{$trialing['count']}** ({$this->money($trialing['mrr'], $currency)} MRR)",
"Current MRR/ARR: **{$this->money($currentMrr, $currency)}** / **{$this->money($currentMrr * 12, $currency)}**",
"Projected MRR/ARR: **{$this->money($projectedMrr, $currency)}** / **{$this->money($projectedMrr * 12, $currency)}**",
]),
'inline' => false,
];
}
if ($currencies === []) {
$fields[] = [
'name' => '💳 Subscriptions',
'value' => 'No active or trialing subscriptions.',
'inline' => false,
];
}
return [
'title' => '📊 Daily Stats — '.$day->format('D, d M Y'),
'color' => 0x5865F2,
'fields' => $fields,
];
}
/**
* MRR figures are held in major units (e.g. euros), so convert to cents for
* the shared formatter.
*/
private function money(float $amount, string $currency): string
{
return Money::format((int) round($amount * 100), $currency);
}
}

View File

@ -1,212 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Features\SubscriptionExperiment;
use App\Services\Discord\DiscordWebhook;
use App\Services\Stats\BinomialProportion;
use App\Services\Stats\ExperimentFunnelCollector;
use App\Services\Stats\ProportionSignificance;
use App\Support\Money;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
class SendExperimentFunnelReportCommand extends Command
{
protected $signature = 'stats:experiment-funnel
{--no-discord : Print the report to the console only, without posting to Discord}
{--cost-per-connection=0.4 : Estimated cost (in the Cashier currency) per bank connection, used for the Cost/Burn/CM columns}';
protected $description = 'Post the trial/pricing experiment funnel (per variant) to Discord';
private const LABELS = [
SubscriptionExperiment::CONTROL => 'control',
SubscriptionExperiment::REDUCED_TRIAL => 'reduced',
SubscriptionExperiment::PAY_NOW => 'pay_now',
];
public function __construct(
private ExperimentFunnelCollector $collector,
private ProportionSignificance $significance,
) {
parent::__construct();
}
public function handle(): int
{
$costPerConnectionCents = (int) round(((float) $this->option('cost-per-connection')) * 100);
$report = $this->collector->collect($costPerConnectionCents);
if ($report['startedAt'] === null) {
$this->warn('Experiment not started — set SUBSCRIPTION_EXPERIMENT_STARTED_AT to begin.');
return self::SUCCESS;
}
foreach ($this->tableLines($report) as $line) {
$this->line($line);
}
foreach ($this->significanceLines($report) as $line) {
$this->line($line);
}
if ($this->option('no-discord')) {
$this->info('Skipped Discord (--no-discord).');
return self::SUCCESS;
}
$webhookUrl = config('services.discord.ai_cohort_webhook_url')
?: config('services.discord.webhook_url');
(new DiscordWebhook($webhookUrl))->send('', [$this->buildEmbed($report)]);
$this->info('Experiment funnel report sent to Discord.');
return self::SUCCESS;
}
/**
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
* @return list<string>
*/
private function tableLines(array $report): array
{
$revenue = $report['revenueAvailable'];
$currency = $report['currency'];
$lines = [sprintf(
'%-8s %5s %5s %5s %5s %5s %6s %7s %7s %7s %7s %7s',
'Variant', 'Assg', 'Actd', 'Card', 'MatU', 'Conv', 'Conv%', 'ARPU', 'MRR', 'Cost', 'Burn', 'CM',
)];
foreach (self::LABELS as $key => $label) {
$row = $report['variants'][$key];
$mature = $row['assignedMature'] > 0;
$showMoney = $revenue && $mature;
$lines[] = sprintf(
'%-8s %5d %5d %5d %5d %5d %6s %7s %7s %7s %7s %7s',
$label,
$row['assigned'],
$row['activated'],
$row['subscribed'],
$row['assignedMature'],
$row['convertedMature'],
$mature ? ((int) round($row['conversionRate'] * 100)).'%' : 'pend',
$showMoney && $row['arpuCents'] !== null ? Money::format($row['arpuCents'], $currency) : '—',
$showMoney ? Money::format($row['mrrCents'], $currency) : '—',
$mature ? Money::format($row['costCents'], $currency) : '—',
$mature ? Money::format($row['wastedCostCents'], $currency) : '—',
$showMoney ? Money::format($row['contributionMarginCents'], $currency) : '—',
);
}
return $lines;
}
/**
* Per-variant conversion-rate uncertainty (95% Wilson interval) plus the
* leader-vs-runner-up verdict from {@see ProportionSignificance} a Fisher
* exact test and a Newcombe difference interval, Bonferroni-corrected so
* "check significance before calling a winner" has the numbers behind it.
*
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
* @return list<string>
*/
private function significanceLines(array $report): array
{
$lines = ['', 'Significance (95% Wilson CI on Conv%, n = MatU):'];
$arms = [];
foreach (self::LABELS as $key => $label) {
$row = $report['variants'][$key];
$n = (int) $row['assignedMature'];
$k = (int) $row['convertedMature'];
if ($n <= 0) {
$lines[] = sprintf(' %-8s pend (n=0)', $label);
continue;
}
[$low, $high] = $this->significance->wilsonInterval($k, $n);
$lines[] = sprintf(' %-8s %6s [%6s %6s] (n=%d)', $label, $this->percent($k / $n), $this->percent($low), $this->percent($high), $n);
$arms[] = new BinomialProportion($label, $k, $n);
}
if (count($arms) < 2) {
$lines[] = 'Not enough matured variants to compare yet.';
return $lines;
}
usort($arms, fn (BinomialProportion $a, BinomialProportion $b): int => $b->rate() <=> $a->rate());
[$leader, $runnerUp] = [$arms[0], $arms[1]];
$result = $this->significance->compare($leader, $runnerUp);
$lines[] = sprintf(
'Leader %s vs %s: Δ %+.1f pts (95%% CI %+.1f … %+.1f pts, Newcombe).',
$leader->label, $runnerUp->label,
($leader->rate() - $runnerUp->rate()) * 100, $result['diffLow'] * 100, $result['diffHigh'] * 100,
);
$lines[] = sprintf(
'Fisher exact p=%.3f %s α=%.3f (Bonferroni×3) -> %s.%s',
$result['fisherP'], $result['significant'] ? '<' : '≥', $result['alpha'],
$result['significant'] ? 'significant' : 'not significant',
$result['significant'] ? '' : ' Keep running.',
);
if ($result['minExpectedCount'] < 5.0) {
$lines[] = sprintf(
'(Small sample: min expected conversions %.1f < 5, so the normal-approx z=%.2f overstates — exact test used.)',
$result['minExpectedCount'], $result['z'],
);
}
return $lines;
}
private function percent(float $rate): string
{
return number_format($rate * 100, 1).'%';
}
/**
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
* @return array<string, mixed>
*/
private function buildEmbed(array $report): array
{
return [
'title' => '🧪 Trial/Pricing Experiment — Funnel by Variant',
'description' => "```\n".implode("\n", $this->tableLines($report))."\n```",
'color' => 0xFEE75C,
'fields' => [
[
'name' => 'Started',
'value' => $report['startedAt']->format('D, d M Y').' · new signups split evenly into the three variants.',
'inline' => false,
],
[
'name' => '📊 Significance',
'value' => "```\n".implode("\n", $this->significanceLines($report))."\n```",
'inline' => false,
],
[
'name' => 'Legend',
'value' => sprintf(
'Assg = signups · Actd = activated (connected a bank or enabled AI = cost triggered) · Card = completed checkout (card on file) · MatU = matured assigned (cohort old enough to score for this variant) · Conv = matured users who ever converted (were charged, net of refund) — time-invariant, so it does not shrink as an older cohort has longer to churn · Conv%% = Conv ÷ MatU (always ≤100%%, comparable across variants) · ARPU = MRR ÷ MatU (revenue per matured user) · MRR = monthly run-rate of *currently* paying subs (yearly ÷ 12); Conv above MRR is churn · Cost = est. connection cost of MatU (%s/connection) · Burn = connection cost of matured users who never earned net revenue (connected a bank but never paid, or paid then refunded) · CM = MRR Cost · `pend`/`—` = no matured data yet.',
Money::format($report['costPerConnectionCents'], $report['currency']),
),
'inline' => false,
],
[
'name' => '⚠️ How to read it',
'value' => 'Each variant matures on its own decision window (control 15d, reduced 7d, pay_now 3d, +3d settle), so at any moment MatU differs a lot between variants (pay_now matures first). **Compare variants on Conv% and ARPU — normalized per matured user — not on the absolute MRR/Cost/Burn/CM totals, which scale with MatU and so mechanically favour whichever variant has matured more.** Assg/Actd/Card are lifetime counts; everything from MatU rightward covers the matured cohort only, so the raw Actd→Card→Conv funnel mixes cohorts (immature carded users can\'t have matured yet) — read it for volume. Conv counts anyone ever charged (net of refund), so it is not depressed for older cohorts the way a live-active snapshot would be. Per-user CM is sub-cent at current volume, so treat CM as directional context, not the decision. Check significance (sample size = MatU) before calling a winner. Cost is a flat per-connection estimate across all providers, not per-provider billing.',
'inline' => false,
],
],
];
}
}

View File

@ -1,48 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Enums\DripEmailType;
use App\Jobs\Drip\SendPaywallFollowUpEmailJob;
use App\Models\User;
use Illuminate\Console\Command;
class SendPaywallFollowUpEmailsCommand extends Command
{
protected $signature = 'email:paywall-follow-up';
protected $description = 'Queue the paywall follow-up email for users who completed onboarding yesterday but are stuck on the paywall';
public function handle(): int
{
if (! config('mail.drip_emails_enabled')) {
$this->info('Drip emails are disabled. Nothing to do.');
return self::SUCCESS;
}
$query = User::query()
->whereDate('onboarded_at', today()->subDay())
->whereHas('bankingConnections')
->whereDoesntHave('mailLogs', function ($query): void {
$query->where('email_type', DripEmailType::PaywallFollowUp);
});
$queued = 0;
$query->chunkById(100, function ($users) use (&$queued): void {
foreach ($users as $user) {
if ($user->hasProPlan()) {
continue;
}
SendPaywallFollowUpEmailJob::dispatch($user);
$queued++;
}
});
$this->info("Queued {$queued} paywall follow-up email(s).");
return self::SUCCESS;
}
}

View File

@ -1,108 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Concerns\RendersReportToConsole;
use App\Models\StuckCohortSnapshot;
use App\Services\Discord\DiscordWebhook;
use App\Services\Stats\StuckCohortReportCollector;
use Illuminate\Console\Command;
class SendStuckCohortReportCommand extends Command
{
use RendersReportToConsole;
protected $signature = 'stats:stuck-cohort-report {--no-discord : Print the report to the console only, without posting to Discord}';
protected $description = 'Post the weekly paywall stuck-cohort report (banked users without a valid subscription) to Discord';
public function __construct(private StuckCohortReportCollector $collector)
{
parent::__construct();
}
public function handle(): int
{
$report = $this->collector->collect();
$embed = $this->buildEmbed($report);
if ($this->option('no-discord')) {
$this->printEmbeds([$embed]);
$this->info('Skipped Discord (--no-discord).');
return self::SUCCESS;
}
$webhookUrl = config('services.discord.ai_cohort_webhook_url')
?: config('services.discord.webhook_url');
(new DiscordWebhook($webhookUrl))->send('', [$embed]);
$this->info('Stuck cohort report sent to Discord.');
return self::SUCCESS;
}
/**
* @param array{snapshot: StuckCohortSnapshot, previous: ?StuckCohortSnapshot, pctDelta: ?float, stuckDelta: ?int} $report
* @return array<string, mixed>
*/
private function buildEmbed(array $report): array
{
$snapshot = $report['snapshot'];
$lines = [
sprintf('Stuck %d', $snapshot->stuck_count),
sprintf('Onboarded %d', $snapshot->onboarded_count),
sprintf('Stuck rate %s%%', $this->formatPct((float) $snapshot->stuck_pct)),
];
if ($report['previous'] !== null) {
$lines[] = '';
$lines[] = sprintf(
'vs %s: %s pp · %s stuck',
$report['previous']->date->format('d M'),
$this->formatSignedPct((float) $report['pctDelta']),
$this->formatSignedInt((int) $report['stuckDelta']),
);
} else {
$lines[] = '';
$lines[] = 'First snapshot — no previous week to compare.';
}
return [
'title' => '🪤 Paywall — Weekly Stuck Cohort',
'description' => "```\n".implode("\n", $lines)."\n```",
'color' => 0xED4245,
'fields' => [
[
'name' => 'Definition',
'value' => 'Stuck = onboarded users with a non-deleted banking connection but no valid subscription (active/trialing/past_due, or canceled but still within the grace period).',
'inline' => false,
],
[
'name' => 'Denominator',
'value' => 'Stuck rate = stuck / onboarded users (`onboarded_at` not null) — the population that has reached the paywall.',
'inline' => false,
],
],
];
}
private function formatPct(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
private function formatSignedPct(float $value): string
{
$sign = $value > 0 ? '+' : '';
return $sign.$this->formatPct($value);
}
private function formatSignedInt(int $value): string
{
return ($value > 0 ? '+' : '').$value;
}
}

View File

@ -1,136 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\Discord\DiscordWebhook;
use App\Services\Stats\SubscriptionFunnelCollector;
use Illuminate\Console\Command;
class SendSubscriptionFunnelReportCommand extends Command
{
protected $signature = 'stats:subscription-funnel {--weeks= : Number of weekly cohorts to include} {--no-discord : Print the report to the console only, without posting to Discord}';
protected $description = 'Post the weekly registration -> subscription -> paid funnel to Discord';
public function __construct(private SubscriptionFunnelCollector $collector)
{
parent::__construct();
}
public function handle(): int
{
$weeks = $this->option('weeks') !== null ? (int) $this->option('weeks') : null;
$report = $this->collector->collect($weeks);
foreach ($this->tableLines($report) as $line) {
$this->line($line);
}
if ($this->option('no-discord')) {
$this->info('Skipped Discord (--no-discord).');
return self::SUCCESS;
}
$webhookUrl = config('services.discord.ai_cohort_webhook_url')
?: config('services.discord.webhook_url');
(new DiscordWebhook($webhookUrl))->send('', [$this->buildEmbed($report)]);
$this->info('Subscription funnel report sent to Discord.');
return self::SUCCESS;
}
/**
* @param array{trialDays: int, weeks: list<array<string, mixed>>} $report
* @return list<string>
*/
private function tableLines(array $report): array
{
$lines = [sprintf('%-9s %5s %5s %5s %5s %5s %5s', 'Week', 'Reg', 'Sub', 'Sub%', 'Paid', 'Pd%', 'T2P')];
foreach ($report['weeks'] as $row) {
$lines[] = sprintf(
'%-9s %5d %5d %5s %5d %5s %5s%s',
$row['week'],
$row['registered'],
$row['subscribed'],
$this->rateCell($row['subscribedRate'], $row['subscribedMature'], $row['registered']),
$row['paid'],
$this->rateCell($row['paidRate'], $row['paidMature'], $row['registered']),
$this->rateCell($row['trialToPaidRate'], $row['paidMature'], $row['subscribed']),
$row['surge'] ? ' ⚡' : '',
);
}
return $lines;
}
/**
* @param array{trialDays: int, weeks: list<array<string, mixed>>} $report
* @return array<string, mixed>
*/
private function buildEmbed(array $report): array
{
$mature = array_values(array_filter($report['weeks'], fn (array $row): bool => $row['paidMature']));
$registered = array_sum(array_column($mature, 'registered'));
$subscribed = array_sum(array_column($mature, 'subscribed'));
$paid = array_sum(array_column($mature, 'paid'));
$totals = $registered > 0
? sprintf(
"Registered %d\nSubscribed %d (%s%%)\nPaid %d (%s%% of reg · %s%% of subs)",
$registered,
$subscribed,
$this->pct($subscribed / $registered),
$paid,
$this->pct($paid / $registered),
$subscribed > 0 ? $this->pct($paid / $subscribed) : '—',
)
: 'No mature cohorts yet.';
return [
'title' => '💸 Subscription Funnel — Weekly Cohorts',
'description' => "```\n".implode("\n", $this->tableLines($report))."\n```",
'color' => 0x57F287,
'fields' => [
[
'name' => 'Mature cohorts (baseline)',
'value' => "```\n".$totals."\n```",
'inline' => false,
],
[
'name' => 'Legend',
'value' => 'Reg = signups · Sub = started a plan ≤30d after signup · Paid = that plan billed past the '.$report['trialDays'].'d trial (active, or canceled only after billing) · Sub%/Pd% of signups · T2P = paid ÷ subscribed · `pend` = cohort too young to score · ⚡ = signup surge',
'inline' => false,
],
[
'name' => '⚠️ Directional only',
'value' => 'Cohorts compared at equal age. Surge weeks (⚡, e.g. launch/marketing) differ in acquisition channel and are not controlled — compare organic weeks like-for-like. This is the pre-A/B baseline, not a randomised test.',
'inline' => false,
],
],
];
}
private function rateCell(?float $rate, bool $mature, int $denominator): string
{
if ($denominator === 0) {
return '—';
}
if (! $mature || $rate === null) {
return 'pend';
}
return $this->pct($rate).'%';
}
private function pct(float $rate): string
{
return (string) ((int) round($rate * 100));
}
}

View File

@ -1,174 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Mail\UserLeadReInvitation;
use App\Models\UserLead;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Mail;
use Throwable;
#[Signature('leads:send-re-invitations
{--limit=50 : Maximum number of leads to re-invite in this batch}
{--email= : Re-invite a specific invited lead by email address}
{--min-days-since-invite=3 : Only include leads invited at least this many days ago}
{--again : Include leads that were already re-invited}
{--stats : Show re-invitation signup stats instead of sending emails}
{--dry-run : Show what would happen without sending emails}
{--force : Skip confirmation prompt}')]
#[Description('Send follow-up invitation emails to invited leads who have not signed up')]
class SendUserLeadReInvitations extends Command
{
public function handle(): int
{
if ((bool) $this->option('stats')) {
$this->displayStats();
return self::SUCCESS;
}
$limit = (int) $this->option('limit');
if ($limit < 1) {
$this->error('Limit must be a positive integer.');
return self::FAILURE;
}
$minDaysSinceInvite = max(0, (int) $this->option('min-days-since-invite'));
$emailFilter = $this->resolveEmailFilter();
if ($emailFilter === false) {
return self::FAILURE;
}
$leads = $this->pendingReInvitationQuery($minDaysSinceInvite, (bool) $this->option('again'))
->when(
$emailFilter !== null,
fn (Builder $query) => $query->where('email', $emailFilter),
fn (Builder $query) => $query->orderBy('invitation_sent_at')->limit($limit),
)
->get();
if ($leads->isEmpty()) {
if ($emailFilter !== null) {
$this->error("No invited lead pending re-invitation found for {$emailFilter}.");
return self::FAILURE;
}
$this->info('No invited leads pending re-invitation found.');
return self::SUCCESS;
}
$this->table(
['#', 'Email', 'Invited at', 'Re-invited at', 'Re-invites'],
$leads->values()->map(fn (UserLead $lead, int $index): array => [
$index + 1,
$lead->email,
$lead->invitation_sent_at?->toDateTimeString(),
$lead->re_invitation_sent_at?->toDateTimeString() ?? '-',
$lead->re_invitation_count ?? 0,
])->all(),
);
if ((bool) $this->option('dry-run')) {
$this->info('[dry-run] No re-invitation emails sent.');
return self::SUCCESS;
}
if (! $this->option('force')) {
if (! $this->confirm('Send these re-invitation emails?', true)) {
$this->info('Cancelled.');
return self::SUCCESS;
}
}
$sent = 0;
$failed = 0;
$progressBar = $this->output->createProgressBar($leads->count());
$progressBar->start();
foreach ($leads as $lead) {
try {
Mail::to($lead->email)->send(new UserLeadReInvitation($lead));
$lead->forceFill([
're_invitation_sent_at' => now(),
're_invitation_count' => ((int) $lead->re_invitation_count) + 1,
])->save();
$sent++;
} catch (Throwable $exception) {
$failed++;
$this->error("Failed for {$lead->email}: {$exception->getMessage()}");
report($exception);
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine();
$this->info("Queued {$sent} re-invitation email(s)".($failed > 0 ? " ({$failed} failed)" : '').'.');
$this->displayStats();
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
/** @return Builder<UserLead> */
private function pendingReInvitationQuery(int $minDaysSinceInvite, bool $includeAlreadyReInvited): Builder
{
return UserLead::query()
->whereNotNull('invitation_sent_at')
->whereDoesntHave('signedUpUser')
->when(! $includeAlreadyReInvited, fn (Builder $query) => $query->whereNull('re_invitation_sent_at'))
->when(
$minDaysSinceInvite > 0,
fn (Builder $query) => $query->where('invitation_sent_at', '<=', now()->subDays($minDaysSinceInvite)),
);
}
private function displayStats(): void
{
$reInvited = UserLead::query()
->whereNotNull('re_invitation_sent_at')
->count();
$signedUpAfterReInvite = UserLead::query()
->whereNotNull('re_invitation_sent_at')
->whereHas('signedUpUser', fn (Builder $query) => $query->whereColumn('users.created_at', '>=', 'user_leads.re_invitation_sent_at'))
->count();
$rate = $reInvited > 0 ? round(($signedUpAfterReInvite / $reInvited) * 100, 2) : 0.0;
$this->table(['Metric', 'Value'], [
['Re-invited leads', $reInvited],
['Re-invited leads signed up', $signedUpAfterReInvite],
['Success rate', $rate.'%'],
]);
}
private function resolveEmailFilter(): string|false|null
{
$email = $this->option('email');
if ($email === null || $email === '') {
return null;
}
$email = strtolower(trim((string) $email));
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error("Invalid email `{$email}`.");
return false;
}
return $email;
}
}

View File

@ -1,89 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\Stripe\SubscriptionStatsCollector;
use App\Support\Money;
use Illuminate\Console\Command;
use Stripe\Exception\ApiErrorException;
class StripeSubscriptionStatsCommand extends Command
{
protected $signature = 'stripe:subscription-stats';
protected $description = 'Show Stripe subscription stats: active/trialing counts and current/projected MRR & ARR';
/**
* @var array<string, array{count: int, mrr: float}>
*/
private array $active = [];
/**
* @var array<string, array{count: int, mrr: float}>
*/
private array $trialing = [];
public function __construct(private SubscriptionStatsCollector $collector)
{
parent::__construct();
}
public function handle(): int
{
try {
$stats = $this->collector->collect();
} catch (ApiErrorException $exception) {
$this->error("Stripe API error: {$exception->getMessage()}");
return self::FAILURE;
}
$this->active = $stats['active'];
$this->trialing = $stats['trialing'];
$this->render();
return self::SUCCESS;
}
private function render(): void
{
$currencies = array_unique(array_merge(array_keys($this->active), array_keys($this->trialing)));
sort($currencies);
if ($currencies === []) {
$this->warn('No active or trialing subscriptions found.');
return;
}
foreach ($currencies as $currency) {
$active = $this->active[$currency] ?? ['count' => 0, 'mrr' => 0.0];
$trialing = $this->trialing[$currency] ?? ['count' => 0, 'mrr' => 0.0];
$currentMrr = $active['mrr'];
$projectedMrr = $active['mrr'] + $trialing['mrr'];
$this->newLine();
$this->line('<options=bold>'.strtoupper($currency).'</>');
$this->line(" Active subs: <fg=green>{$active['count']}</> ({$this->format($active['mrr'], $currency)} MRR)");
$this->line(" Trialing subs: <fg=yellow>{$trialing['count']}</> ({$this->format($trialing['mrr'], $currency)} MRR)");
$this->newLine();
$this->line(" Current MRR: <fg=cyan>{$this->format($currentMrr, $currency)}</>");
$this->line(" Current ARR: <fg=cyan>{$this->format($currentMrr * 12, $currency)}</>");
$this->line(" Projected MRR: <fg=cyan>{$this->format($projectedMrr, $currency)}</> (if trialing convert)");
$this->line(" Projected ARR: <fg=cyan>{$this->format($projectedMrr * 12, $currency)}</>");
}
$this->newLine();
}
/**
* MRR figures are held in major units (e.g. euros), so convert to cents for
* the shared formatter.
*/
private function format(float $amount, string $currency): string
{
return Money::format((int) round($amount * 100), $currency);
}
}

View File

@ -1,216 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Enums\SuggestionRunStatus;
use App\Models\RuleSuggestion;
use App\Models\User;
use App\Services\Ai\Contracts\RuleSuggestionGenerator;
use App\Services\Ai\GenerateRuleSuggestions;
use App\Services\Ai\RuleSuggestionAggregator;
use App\Services\Ai\RuleSuggestionAvailability;
use App\Services\Ai\RuleSuggestionGuard;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Throwable;
class SuggestRulesCommand extends Command
{
protected $signature = 'ai:suggest-rules
{user : User id or email}
{--persist : Run through the real pipeline and store a SuggestionRun instead of a dry run}';
protected $description = 'Generate AI rule suggestions for a user and print what each stage produces';
public function handle(
RuleSuggestionAggregator $aggregator,
RuleSuggestionGenerator $generator,
RuleSuggestionGuard $guard,
RuleSuggestionAvailability $availability,
): int {
$user = $this->resolveUser((string) $this->argument('user'));
if ($user === null) {
$this->error('User not found.');
return self::FAILURE;
}
$this->line("User: <info>{$user->email}</info> ({$user->id})");
$this->line(sprintf(
'Transactions: %d · eligible: %s · throttled: %s',
$availability->transactionCount($user),
$availability->isEligible($user) ? 'yes' : 'no',
$availability->isThrottled($user) ? 'yes' : 'no',
));
$this->newLine();
if ($this->option('persist')) {
return $this->runPersisted($user);
}
return $this->runDryRun($user, $aggregator, $generator, $guard);
}
private function runDryRun(
User $user,
RuleSuggestionAggregator $aggregator,
RuleSuggestionGenerator $generator,
RuleSuggestionGuard $guard,
): int {
$groups = $aggregator->groupsFor($user);
if ($groups === []) {
$this->warn('No transaction groups to suggest from (need uncategorized, server-readable transactions).');
return self::SUCCESS;
}
$this->components->info(count($groups).' transaction group(s) sent to the model');
$this->table(
['Key', 'Field', 'Count', 'Direction', 'Avg', 'Samples'],
array_map(fn (array $group): array => [
$group['key'],
$group['field'],
$group['count'],
$group['direction'],
$group['avg_amount'],
Str::limit(implode(' | ', $group['samples']), 50),
], $groups),
);
$categories = $aggregator->categoryOptions($user);
try {
$raw = $generator->generate($groups, $categories);
} catch (Throwable $exception) {
$this->error('Model call failed: '.$exception->getMessage());
return self::FAILURE;
}
$this->components->info(count($raw).' raw suggestion(s) returned by the model');
$this->table(
['Group', 'Field', 'Op', 'Token', 'Category', 'Confidence'],
array_map(fn (array $suggestion): array => [
$suggestion['group_key'] ?? '',
$suggestion['match_field'] ?? '',
$suggestion['match_operator'] ?? '',
$suggestion['match_token'] ?? '',
$this->describeRawCategory($suggestion, $categories),
$suggestion['confidence'] ?? '',
], $raw),
);
$validated = $guard->validate($user, $raw, $categories);
$this->components->info(count($validated).' suggestion(s) survived the guards');
$this->table(
['Field', 'Op', 'Token', 'Category', 'Confidence', 'Matches'],
array_map(fn (array $suggestion): array => [
$suggestion['match_field'],
$suggestion['match_operator'],
$suggestion['match_token'],
$this->describeValidatedCategory($suggestion, $categories),
$suggestion['confidence'],
$suggestion['group_size'],
], $validated),
);
$this->newLine();
$this->line('<comment>Dry run — nothing was saved. Use --persist to store a SuggestionRun.</comment>');
return self::SUCCESS;
}
private function runPersisted(User $user): int
{
$run = $user->suggestionRuns()->create(['status' => SuggestionRunStatus::Pending]);
app(GenerateRuleSuggestions::class)->run($run);
$run->refresh()->load('suggestions.proposedCategory');
$this->components->info("Run {$run->id} finished with status: {$run->status->value}");
if ($run->error) {
$this->error($run->error);
return self::FAILURE;
}
if ($run->suggestions->isEmpty()) {
$this->warn('No suggestions were produced.');
return self::SUCCESS;
}
$this->table(
['Field', 'Op', 'Token', 'Category', 'Confidence', 'Matches'],
$run->suggestions->map(fn (RuleSuggestion $suggestion): array => [
$suggestion->match_field,
$suggestion->match_operator,
$suggestion->match_token,
$suggestion->proposed_category_id !== null
? $suggestion->proposedCategory->name
: ($suggestion->new_category_name !== null
? "NEW: {$suggestion->new_category_name}"
: '—'),
$suggestion->confidence,
$suggestion->group_size,
])->all(),
);
return self::SUCCESS;
}
private function resolveUser(string $identifier): ?User
{
return User::query()->where('email', $identifier)->first()
?? User::query()->find($identifier);
}
/**
* @param array<string, mixed> $suggestion
* @param list<array<string, mixed>> $categories
*/
private function describeRawCategory(array $suggestion, array $categories): string
{
$id = $suggestion['category_id'] ?? null;
if (filled($id)) {
foreach ($categories as $category) {
if ($category['id'] === $id) {
return (string) $category['path'];
}
}
return (string) $id;
}
$name = $suggestion['new_category_name'] ?? null;
return filled($name) ? "NEW: {$name}" : '—';
}
/**
* @param array<string, mixed> $suggestion
* @param list<array<string, mixed>> $categories
*/
private function describeValidatedCategory(array $suggestion, array $categories): string
{
if (filled($suggestion['proposed_category_id'])) {
foreach ($categories as $category) {
if ($category['id'] === $suggestion['proposed_category_id']) {
return (string) $category['path'];
}
}
return (string) $suggestion['proposed_category_id'];
}
return filled($suggestion['new_category_name'])
? "NEW: {$suggestion['new_category_name']} ({$suggestion['new_category_direction']})"
: '—';
}
}

View File

@ -3,7 +3,6 @@
namespace App\Console\Commands;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Jobs\SyncAllBankingConnectionsJob;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\BankingConnection;
@ -56,7 +55,7 @@ class SyncBankingConnections extends Command
->orWhere('valid_until', '>', now());
});
})->orWhere(function ($query) {
$query->where('provider', BankingProvider::EnableBanking)
$query->where('provider', 'enablebanking')
->where('status', BankingConnectionStatus::Active)
->whereNotNull('valid_until')
->where('valid_until', '<=', now());
@ -93,9 +92,9 @@ class SyncBankingConnections extends Command
$connections->each(function (BankingConnection $connection) use ($sync, $fullSync) {
if ($sync) {
$this->info("Syncing {$connection->provider->value} connection {$connection->id}...");
$this->info("Syncing {$connection->provider} connection {$connection->id}...");
SyncBankingConnectionJob::dispatchSync($connection, $fullSync);
$this->info("Finished syncing {$connection->provider->value} connection {$connection->id}.");
$this->info("Finished syncing {$connection->provider} connection {$connection->id}.");
} else {
SyncBankingConnectionJob::dispatch($connection, $fullSync);
}

View File

@ -2,7 +2,6 @@
namespace App\Console\Commands;
use App\Support\Money;
use Illuminate\Console\Command;
use Laravel\Cashier\Cashier;
use Stripe\Exception\ApiErrorException;
@ -51,7 +50,6 @@ class SyncStripePricesCommand extends Command
$amountInCents = (int) round($plan['price'] * 100);
$billingPeriod = $plan['billing_period'] ?? null;
$productId = config('subscriptions.products.pro');
$formattedAmount = Money::format($amountInCents, $currency);
$this->line(" <options=bold>{$plan['name']}</>");
@ -70,7 +68,7 @@ class SyncStripePricesCommand extends Command
if (! $dryRun) {
$price = $this->createPrice($productId, $amountInCents, $currency, $billingPeriod, $lookupKey, transferLookupKey: true);
$this->line(" <fg=green>✓</> Transferred to {$price->id} ({$formattedAmount}/{$billingPeriod})");
$this->line(" <fg=green>✓</> Transferred to {$price->id} ({$this->formatAmount($amountInCents, $currency)}/{$billingPeriod})");
} else {
$this->line(" <fg=cyan>[dry-run]</> Would create new price and transfer lookup key '{$lookupKey}'");
}
@ -81,9 +79,9 @@ class SyncStripePricesCommand extends Command
if (! $dryRun) {
$price = $this->createPrice($productId, $amountInCents, $currency, $billingPeriod, $lookupKey, transferLookupKey: false);
$this->line(" <fg=green>✓</> Created {$price->id} ({$formattedAmount}/{$billingPeriod})");
$this->line(" <fg=green>✓</> Created {$price->id} ({$this->formatAmount($amountInCents, $currency)}/{$billingPeriod})");
} else {
$this->line(" <fg=cyan>[dry-run]</> Would create price '{$lookupKey}' at {$formattedAmount}/{$billingPeriod}");
$this->line(" <fg=cyan>[dry-run]</> Would create price '{$lookupKey}' at {$this->formatAmount($amountInCents, $currency)}/{$billingPeriod}");
}
$created++;
@ -150,4 +148,16 @@ class SyncStripePricesCommand extends Command
return $currencyMatches && $amountMatches && $intervalMatches;
}
private function formatAmount(int $amountInCents, string $currency): string
{
$symbol = match (strtolower($currency)) {
'eur' => '€',
'gbp' => '£',
'jpy' => '¥',
default => strtoupper($currency).' ',
};
return $symbol.number_format($amountInCents / 100, 2);
}
}

View File

@ -1,105 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Actions\Subscription\RefundSelfServe;
use App\Features\SubscriptionExperiment;
use App\Models\User;
use App\Services\Subscriptions\ExperimentOffer;
use Illuminate\Console\Command;
use Laravel\Cashier\Cashier;
use Laravel\Pennant\Feature;
/**
* Live sandbox check for the pay_now self-service refund the one path that
* Pest tests can only mock. It creates a real, immediately-charged subscription
* against the Stripe test environment, runs the actual RefundSelfServe action,
* and confirms via the Stripe API that the charge was refunded and the
* subscription canceled. Run before flipping SUBSCRIPTION_EXPERIMENT_STARTED_AT.
*
* Refuses to run against anything but Stripe test keys.
*/
class VerifyRefundFlowCommand extends Command
{
protected $signature = 'stripe:verify-refund';
protected $description = 'Verify the pay_now self-service refund end-to-end against the Stripe sandbox';
public function __construct(private ExperimentOffer $offer)
{
parent::__construct();
}
public function handle(): int
{
if (app()->isProduction() || ! str_starts_with((string) config('cashier.secret'), 'sk_test')) {
$this->error('Refusing to run: this command requires Stripe test keys and a non-production environment.');
return self::FAILURE;
}
config(['subscriptions.tax_rates' => []]);
$passed = true;
$check = function (string $label, bool $ok) use (&$passed): void {
$this->line(($ok ? '<fg=green>PASS</>' : '<fg=red>FAIL</>').' '.$label);
$passed = $passed && $ok;
};
$lookup = config('subscriptions.plans.monthly.stripe_lookup_key');
$prices = Cashier::stripe()->prices->all(['lookup_keys' => [$lookup], 'limit' => 1]);
$priceId = $prices->data[0]->id ?? null;
if ($priceId === null) {
$this->error("No Stripe price found for lookup key '{$lookup}'.");
return self::FAILURE;
}
$user = User::factory()->create([
'email' => 'refund-sandbox-'.uniqid().'@whisper.test',
'created_at' => now(),
]);
Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::PAY_NOW);
try {
$user->newSubscription('default', $priceId)->create('pm_card_visa');
$subscription = $user->subscription('default');
$check('subscription active after immediate charge', $subscription->active() && $subscription->stripe_status === 'active');
$check('canSelfRefund is true before refund', $this->offer->canSelfRefund($user));
$paymentIntentId = $subscription->latestPayment()?->asStripePaymentIntent()->id;
$check('latestPayment() resolves a payment intent', $paymentIntentId !== null);
app(RefundSelfServe::class)->handle($user->fresh());
$subscription = $user->subscription('default')->fresh();
$check('refunded_at is stamped', $subscription->refunded_at !== null);
$check('subscription is canceled', $subscription->canceled());
$check('canSelfRefund is false after refund', ! $this->offer->canSelfRefund($user->fresh()));
$intent = Cashier::stripe()->paymentIntents->retrieve($paymentIntentId, ['expand' => ['latest_charge']]);
$charge = $intent->latest_charge;
$check('Stripe charge shows a full refund', is_object($charge) && $charge->refunded === true);
$this->line(' amount_refunded='.($charge->amount_refunded ?? 'n/a'));
} catch (\Throwable $exception) {
$this->error($exception::class.': '.$exception->getMessage());
$passed = false;
} finally {
try {
if ($user->hasStripeId()) {
Cashier::stripe()->customers->delete($user->stripe_id);
}
} catch (\Throwable) {
// best-effort sandbox cleanup
}
$user->forceDelete();
}
$this->newLine();
$this->{$passed ? 'info' : 'error'}($passed ? 'Refund flow verified.' : 'Refund flow verification FAILED.');
return $passed ? self::SUCCESS : self::FAILURE;
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Contracts;
use App\Models\BankingConnection;
interface BankingConnectionSyncer
{
/**
* Sync every account belonging to the connection.
*
* @return array<string, mixed> Metadata to persist on the sync log.
*/
public function sync(BankingConnection $connection, bool $isFirstSync): array;
/**
* Whether the connection's consent can expire (consent-based providers).
*/
public function expires(): bool;
/**
* Whether a permanent auth failure should notify the user (API-key providers).
*/
public function notifiesOnAuthFailure(): bool;
}

View File

@ -14,12 +14,9 @@ interface BankingProviderInterface
/**
* Start a user authorization flow.
*
* The $state value is echoed back by the provider on the callback and is used to
* correlate the callback to its connection without relying on a logged-in session.
*
* @return array{url: string, authorization_id: string}
*/
public function startAuthorization(string $aspspName, string $countryCode, string $redirectUrl, string $state): array;
public function startAuthorization(string $aspspName, string $countryCode, string $redirectUrl): array;
/**
* Exchange a callback code for a session with accounts.
@ -45,7 +42,7 @@ interface BankingProviderInterface
/**
* Get session details and status.
*
* @return array{status: string, access: array, accounts: array, accounts_data?: array}
* @return array{status: string, access: array, accounts: array}
*/
public function getSession(string $sessionId): array;

View File

@ -23,17 +23,7 @@ enum AccountType: string
public function reducesNetWorth(): bool
{
return $this === self::Loan;
}
/**
* Whether this account type is part of the net worth total at all. Credit
* cards are spending accounts, not wealth, so they are excluded entirely
* (neither added nor subtracted) while still being tracked on their own.
*/
public function countsInNetWorth(): bool
{
return $this !== self::CreditCard;
return in_array($this, [self::CreditCard, self::Loan], true);
}
/**
@ -43,27 +33,4 @@ enum AccountType: string
{
return in_array($this, [self::Investment, self::Retirement, self::RealEstate], true);
}
/**
* Whether this account type surfaces a transaction ledger in the UI. The
* account detail page renders a transaction list only for these types.
*
* Mirrors the frontend `isTransactionalAccount`. This is intentionally
* distinct from isNonTransactional(): that is the narrower balance-only
* concept used for net-worth charts and treats loan accounts as
* balance-tracking-with-amortization, whereas a loan has no editable ledger.
*/
public function hasTransactionLedger(): bool
{
return ! in_array($this, [self::Investment, self::Loan, self::Retirement, self::RealEstate], true);
}
/**
* Whether a bank connection can sync transactions into this account type.
* Excludes balance/value-tracking types (loan, investment, retirement, real estate).
*/
public function canSyncBankTransactions(): bool
{
return in_array($this, [self::Checking, self::CreditCard, self::Savings, self::Others], true);
}
}

View File

@ -1,9 +0,0 @@
<?php
namespace App\Enums;
enum AnalysisMode: string
{
case Expense = 'expense';
case Income = 'income';
}

View File

@ -1,106 +0,0 @@
<?php
namespace App\Enums;
use App\Services\Banking\CredentialField;
enum BankingProvider: string
{
case IndexaCapital = 'indexacapital';
case Binance = 'binance';
case Bitpanda = 'bitpanda';
case Coinbase = 'coinbase';
case InteractiveBrokers = 'interactivebrokers';
case Wise = 'wise';
case EnableBanking = 'enablebanking';
/**
* Whether the provider authenticates with user-supplied API keys
* rather than EnableBanking's hosted OAuth flow.
*/
public function usesApiKey(): bool
{
return $this !== self::EnableBanking;
}
/**
* The account type that this provider's pending accounts default to.
*/
public function defaultAccountType(): AccountType
{
return match ($this) {
self::IndexaCapital, self::Binance, self::Bitpanda, self::Coinbase, self::InteractiveBrokers => AccountType::Investment,
self::Wise, self::EnableBanking => AccountType::Checking,
};
}
/**
* The credential inputs this provider collects, each mapped to the
* encrypted connection column it is stored in, with its validation rules.
*
* Single source of truth for credential shape: the connect controllers,
* the update request and the update controller all derive from this so a
* new provider is described in exactly one place. Empty for consent-based
* providers that authenticate without user-supplied credentials.
*
* @return array<int, CredentialField>
*/
public function credentialFields(): array
{
return match ($this) {
self::IndexaCapital, self::Wise => [
new CredentialField('api_token', 'api_token', ['required', 'string', 'min:10']),
],
self::Binance => [
new CredentialField('api_key', 'api_token', ['required', 'string', 'min:10']),
new CredentialField('api_secret', 'api_secret', ['required', 'string', 'min:10']),
],
self::Bitpanda => [
new CredentialField('api_key', 'api_token', ['required', 'string', 'min:10']),
],
self::Coinbase => [
new CredentialField('api_key_name', 'api_token', ['required', 'string', 'regex:/^(organizations\/[a-z0-9-]+\/apiKeys\/[a-z0-9-]+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i']),
new CredentialField('private_key', 'api_secret', ['required', 'string', 'min:40']),
],
self::InteractiveBrokers => [
new CredentialField('token', 'api_token', ['required', 'string', 'min:10']),
new CredentialField('query_id', 'api_secret', ['required', 'string', 'min:3']),
],
self::EnableBanking => [],
};
}
/**
* Validation rules for this provider's credential inputs, keyed by input
* name. Shared by the connect Form Requests and the update request.
*
* @return array<string, array<int, mixed>>
*/
public function credentialRules(): array
{
$rules = [];
foreach ($this->credentialFields() as $field) {
$rules[$field->input] = $field->rules;
}
return $rules;
}
/**
* Map validated request input to the encrypted connection columns.
*
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
public function credentialColumns(array $input): array
{
$columns = [];
foreach ($this->credentialFields() as $field) {
$columns[$field->column] = $input[$field->input];
}
return $columns;
}
}

View File

@ -1,10 +0,0 @@
<?php
namespace App\Enums;
enum CategoryDeletionStrategy: string
{
case Reparent = 'reparent';
case Promote = 'promote';
case Cascade = 'cascade';
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Enums;
enum CategorySource: string
{
case Manual = 'manual';
case Rule = 'rule';
case Ai = 'ai';
case Bank = 'bank';
public function label(): string
{
return match ($this) {
self::Manual => 'Manual',
self::Rule => 'Rule',
self::Ai => 'AI',
self::Bank => 'Bank',
};
}
public function isAi(): bool
{
return $this === self::Ai;
}
}

View File

@ -7,8 +7,6 @@ enum CategoryType: string
case Income = 'income';
case Expense = 'expense';
case Transfer = 'transfer';
case Savings = 'savings';
case Investment = 'investment';
public function label(): string
{
@ -16,8 +14,6 @@ enum CategoryType: string
self::Income => 'Income',
self::Expense => 'Expense',
self::Transfer => 'Transfer',
self::Savings => 'Savings',
self::Investment => 'Investment',
};
}
}

View File

@ -11,7 +11,5 @@ enum DripEmailType: string
case ImportHelp = 'import_help';
case Feedback = 'feedback';
case SubscriptionCancelled = 'subscription_cancelled';
case PaywallFollowUp = 'paywall_follow_up';
case AiConsentFollowUp = 'ai_consent_follow_up';
case Update = 'update';
}

View File

@ -1,9 +0,0 @@
<?php
namespace App\Enums;
enum ImportConfigType: string
{
case Transaction = 'transaction';
case Balance = 'balance';
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Enums;
enum IntegrationRequestStatus: string
{
case Pending = 'pending';
case Approved = 'approved';
case InProgress = 'in_progress';
case Rejected = 'rejected';
case NotDoable = 'not_doable';
case Done = 'done';
public function label(): string
{
return match ($this) {
self::Pending => 'Pending',
self::Approved => 'Approved',
self::InProgress => 'In progress',
self::Rejected => 'Rejected',
self::NotDoable => 'Not doable',
self::Done => 'Done',
};
}
}

View File

@ -1,27 +0,0 @@
<?php
namespace App\Enums;
enum Locale: string
{
case English = 'en';
case Spanish = 'es';
case French = 'fr';
/**
* Detect the best-matching locale from an Accept-Language header,
* falling back to English.
*/
public static function detectFromHeader(?string $acceptLanguage): self
{
$acceptLanguage ??= '';
foreach ([self::Spanish, self::French] as $locale) {
if ($acceptLanguage === $locale->value || preg_match('/^'.$locale->value.'(-|,|;)/i', $acceptLanguage) === 1) {
return $locale;
}
}
return self::English;
}
}

View File

@ -1,20 +0,0 @@
<?php
namespace App\Enums;
enum PlanFeature: string
{
case ConnectedAccounts = 'connected_accounts';
case AiSuggestions = 'ai_suggestions';
case McpAccess = 'mcp_access';
/**
* Whether access to this feature is gated behind a paid (Pro) plan.
*/
public function requiresProPlan(): bool
{
return match ($this) {
self::ConnectedAccounts, self::AiSuggestions, self::McpAccess => true,
};
}
}

View File

@ -1,24 +0,0 @@
<?php
namespace App\Enums;
enum RuleOrigin: string
{
case User = 'user';
case Ai = 'ai';
case Correction = 'correction';
public function label(): string
{
return match ($this) {
self::User => 'User',
self::Ai => 'AI',
self::Correction => 'Correction',
};
}
public function isAi(): bool
{
return $this === self::Ai;
}
}

View File

@ -1,10 +0,0 @@
<?php
namespace App\Enums;
enum RuleSuggestionStatus: string
{
case Pending = 'pending';
case Accepted = 'accepted';
case Dismissed = 'dismissed';
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Enums;
enum SuggestionRunStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Completed = 'completed';
case Empty = 'empty';
case Failed = 'failed';
/**
* Whether this run produced usable suggestions and should count against
* the monthly throttle.
*/
public function countsTowardThrottle(): bool
{
return $this === self::Completed;
}
public function isFinished(): bool
{
return in_array($this, [self::Completed, self::Empty, self::Failed], true);
}
}

View File

@ -7,5 +7,4 @@ enum TransactionSource: string
case ManuallyCreated = 'manually_created';
case Imported = 'imported';
case EnableBanking = 'enablebanking';
case Wise = 'wise';
}

View File

@ -1,20 +0,0 @@
<?php
namespace App\Enums;
/**
* The upsell entry point a subscription checkout was started from, used to
* attribute revenue to each upgrade prompt. The value is carried into Stripe as
* subscription metadata and persisted onto the local subscription so revenue
* can be measured per upsell point.
*
* Mirrored on the frontend by the UpsellSource union in
* resources/js/components/subscription/upgrade-dialog.tsx keep both in sync
* when adding a point (an unknown value is silently dropped by tryFrom()).
*/
enum UpsellSource: string
{
case AiCategorization = 'ai_categorization';
case Connections = 'connections';
case Accounts = 'accounts';
}

View File

@ -1,17 +0,0 @@
<?php
namespace App\Exceptions\Banking;
use Exception;
use Illuminate\Contracts\Debug\ShouldntReport;
use Throwable;
class ExpiredBankingSessionException extends Exception implements ShouldntReport
{
public function __construct(
string $message,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace App\Exceptions\Banking;
use Exception;
use Illuminate\Contracts\Debug\ShouldntReport;
use Throwable;
class InaccessibleBankAccountException extends Exception implements ShouldntReport
{
public function __construct(
string $message,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}

View File

@ -1,24 +0,0 @@
<?php
namespace App\Exceptions\Banking;
use Exception;
use Illuminate\Contracts\Debug\ShouldntReport;
use Throwable;
/**
* The banking provider rejected the requested transactions date range as wider
* than the bank is willing to serve (EnableBanking HTTP 422 "Wrong transactions
* period requested"). Recoverable by retrying with a narrower window, so it is
* not reported: the sync layer clamps and retries, and only skips the account
* if even the narrowest window is refused.
*/
class WrongTransactionsPeriodException extends Exception implements ShouldntReport
{
public function __construct(
string $message,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}

View File

@ -1,22 +0,0 @@
<?php
namespace App\Features;
use App\Models\User;
/**
* Gates the MCP access settings screen while the feature is being rolled out.
* Toggle per user / everyone with `php artisan feature:enable App\\Features\\Mcp <target>`.
*
* @api
*/
class Mcp
{
/**
* Resolve the feature's initial value.
*/
public function resolve(?User $user): bool
{
return false;
}
}

View File

@ -1,70 +0,0 @@
<?php
namespace App\Features;
use App\Models\User;
use Carbon\CarbonImmutable;
/**
* A/B/C assignment for the trial/pricing experiment.
*
* Users who registered before `subscriptions.experiment.started_at` (or any user
* while it is null) are "legacy" and behave like the control group. Everyone who
* registered on or after the start is split evenly into the three variants by a
* stable hash of their id, so the bucket never changes for a given user.
*
* The split is deterministic (crc32(id) % 3) and persisted by Pennant; the funnel
* report reads that persisted value so it always matches what the user was served.
*
* @api
*/
class SubscriptionExperiment
{
public const LEGACY = 'legacy';
public const CONTROL = 'control';
public const REDUCED_TRIAL = 'reduced_trial';
public const PAY_NOW = 'pay_now';
/**
* In-memory override that pins every user to the winning variant once the
* experiment is decided. Returning non-null skips both storage and resolve,
* so flipping SUBSCRIPTION_EXPERIMENT_FORCE_VARIANT rolls the winner out to
* everyone without a deploy and without rewriting stored assignments.
*/
public function before(?User $user): ?string
{
$forced = config('subscriptions.experiment.force_variant');
return in_array($forced, [self::CONTROL, self::REDUCED_TRIAL, self::PAY_NOW], true)
? $forced
: null;
}
public function resolve(?User $user): string
{
$startedAt = config('subscriptions.experiment.started_at');
if ($user === null || $startedAt === null || $user->created_at?->lt(CarbonImmutable::parse($startedAt))) {
return self::LEGACY;
}
return self::bucket((string) $user->getKey());
}
/**
* Deterministic, evenly-split bucket for a post-start user. The funnel report
* mirrors this in PHP to attribute users without reading Pennant per row, so
* keep the formula here as the single source of truth.
*/
public static function bucket(string $key): string
{
return match (crc32($key) % 3) {
0 => self::CONTROL,
1 => self::REDUCED_TRIAL,
default => self::PAY_NOW,
};
}
}

View File

@ -3,15 +3,11 @@
namespace App\Http\Controllers;
use App\Enums\AccountType;
use App\Http\Requests\ReorderAccountsRequest;
use App\Http\Requests\UpdateAccountVisibilityRequest;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\LoanDetail;
use App\Services\AccountMetricsService;
use App\Services\LoanAmortizationService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@ -31,149 +27,140 @@ class AccountController extends Controller
$accounts = Account::query()
->where('user_id', $user->id)
->with(['bank', 'realEstateDetail:id,account_id,linked_loan_account_id'])
->orderBy('position')
->with(['bank:id,name,logo', 'realEstateDetail:account_id,linked_loan_account_id'])
->orderByRaw("FIELD(type, 'checking', 'savings', 'investment', 'retirement', 'real_estate', 'loan', 'credit_card', 'others')")
->orderBy('name')
->get();
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']);
// The real estate detail is loaded only to feed the linked_loan_account_id
// accessor; it should not be serialized as a nested relation here.
$accounts->makeHidden('realEstateDetail');
$accountsData = $accounts->map(function (Account $account) {
$data = $account->only(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id', 'bank']);
if ($account->type === AccountType::RealEstate && $account->realEstateDetail?->linked_loan_account_id) {
$data['linked_loan_account_id'] = $account->realEstateDetail->linked_loan_account_id;
}
return $data;
});
return Inertia::render('Accounts/Index', [
'accounts' => $accounts,
'accounts' => $accountsData,
'accountMetrics' => Inertia::defer(fn () => $this->accountMetricsService->getAccountMetrics($user->currency_code, $accounts)),
]);
}
public function reorder(ReorderAccountsRequest $request): RedirectResponse
{
// ponytail: one update per account; fine for the handful of accounts a
// user has. Switch to a single CASE update if that ever grows large.
foreach (array_values($request->validated('ids')) as $position => $id) {
Account::query()
->whereKey($id)
->where('user_id', $request->user()->id)
->update(['position' => $position]);
}
return back();
}
public function updateVisibility(UpdateAccountVisibilityRequest $request, Account $account): RedirectResponse
{
$account->update(['hidden_on_dashboard' => $request->validated('hidden')]);
return back();
}
public function show(Request $request, Account $account): Response
{
$this->authorize('view', $account);
$account->load('bank');
$account->load('bank:id,name,logo');
$data = $account->toArray();
$data = $account->only(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id', 'bank']);
if ($account->type === AccountType::RealEstate) {
$account->load('realEstateDetail.linkedLoanAccount.bank');
$account->load('realEstateDetail.linkedLoanAccount.bank:id,name,logo');
$realEstateDetail = $account->realEstateDetail;
if ($realEstateDetail) {
$linkedLoan = $realEstateDetail->linkedLoanAccount;
$data['real_estate_detail'] = [
...$realEstateDetail->toArray(),
'linked_loan_account' => $linkedLoan?->toArray(),
...$realEstateDetail->only([
'id', 'property_type', 'address', 'purchase_price',
'area_value', 'area_unit', 'notes',
'revaluation_percentage', 'linked_loan_account_id',
]),
'purchase_date' => $realEstateDetail->purchase_date?->format('Y-m-d'),
'linked_loan_account' => $linkedLoan
? $linkedLoan->only(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank'])
: null,
];
// Include current balances for equity calculation
if ($linkedLoan) {
$data['real_estate_detail']['current_loan_balance'] = $this->latestBalance($linkedLoan->id);
$data['real_estate_detail']['current_loan_balance'] = AccountBalance::query()
->where('account_id', $linkedLoan->id)
->where('balance_date', '<=', now()->toDateString())
->orderByDesc('balance_date')
->value('balance') ?? 0;
// Include linked loan account at top level for header actions
$data['linked_loan_account'] = $linkedLoan->toArray();
$data['linked_loan_account'] = $linkedLoan->only(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank', 'banking_connection_id']);
// Load loan amortization details for the linked loan
$linkedLoan->load('loanDetail');
$loanDetail = $linkedLoan->loanDetail;
if ($linkedLoan->loanDetail) {
$data['loan_detail'] = $this->loanDetailData($linkedLoan->loanDetail, $linkedLoan);
if ($loanDetail) {
$remainingMonths = $this->loanAmortizationService->calculateRemainingMonths($loanDetail, now());
$lastLoanBalance = AccountBalance::query()
->where('account_id', $linkedLoan->id)
->orderBy('balance_date', 'desc')
->value('balance');
$monthlyPayment = $this->loanAmortizationService->calculateMonthlyPayment(
$lastLoanBalance ?? $loanDetail->original_amount,
(float) $loanDetail->annual_interest_rate,
$lastLoanBalance ? $remainingMonths : $loanDetail->loan_term_months,
);
$data['loan_detail'] = [
...$loanDetail->only([
'id', 'annual_interest_rate', 'loan_term_months',
'start_date', 'original_amount',
]),
'monthly_payment' => $monthlyPayment,
'remaining_months' => $remainingMonths,
];
}
}
$data['real_estate_detail']['current_market_value'] = $this->latestBalance($account->id);
$data['real_estate_detail']['current_market_value'] = AccountBalance::query()
->where('account_id', $account->id)
->where('balance_date', '<=', now()->toDateString())
->orderByDesc('balance_date')
->value('balance') ?? 0;
}
// Provide available loan accounts for linking
$data['available_loan_accounts'] = $request->user()
->accounts()
->where('type', AccountType::Loan->value)
->with('bank')
->get();
->with('bank:id,name,logo')
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
}
if ($account->type === AccountType::Loan) {
$account->load('loanDetail');
$loanDetail = $account->loanDetail;
if ($account->loanDetail) {
$data['loan_detail'] = $this->loanDetailData($account->loanDetail, $account);
if ($loanDetail) {
$remainingMonths = $this->loanAmortizationService->calculateRemainingMonths($loanDetail, now());
$lastBalance = AccountBalance::query()
->where('account_id', $account->id)
->orderBy('balance_date', 'desc')
->value('balance');
$monthlyPayment = $this->loanAmortizationService->calculateMonthlyPayment(
$lastBalance ?? $loanDetail->original_amount,
(float) $loanDetail->annual_interest_rate,
$lastBalance ? $remainingMonths : $loanDetail->loan_term_months,
);
$data['loan_detail'] = [
...$loanDetail->only([
'id', 'annual_interest_rate', 'loan_term_months',
'start_date', 'original_amount',
]),
'monthly_payment' => $monthlyPayment,
'remaining_months' => $remainingMonths,
];
}
}
return Inertia::render('Accounts/Show', [
'account' => $data,
// Deferred so the page shell paints without blocking on the ledger
// query/serialization. It stays the whole set because search and
// filtering run client-side over decrypted rows. ponytail: window it
// server-side only if one account's history gets big enough that the
// transfer itself hurts.
'transactions' => $account->type->hasTransactionLedger()
? Inertia::defer(fn () => $account->transactions()
->with(['category', 'labels'])
->orderBy('transaction_date', 'desc')
->orderBy('id', 'desc')
->get())
: [],
]);
}
/**
* Build the loan detail payload, augmenting the model with the computed
* amortization figures that depend on the account's latest balance.
*
* @return array<string, mixed>
*/
private function loanDetailData(LoanDetail $loanDetail, Account $account): array
{
$remainingMonths = $this->loanAmortizationService->calculateRemainingMonths($loanDetail, now());
$lastBalance = AccountBalance::query()
->where('account_id', $account->id)
->orderBy('balance_date', 'desc')
->value('balance');
$monthlyPayment = $this->loanAmortizationService->calculateMonthlyPayment(
$lastBalance ?? $loanDetail->original_amount,
(float) $loanDetail->annual_interest_rate,
$lastBalance ? $remainingMonths : $loanDetail->loan_term_months,
);
return [
...$loanDetail->toArray(),
'monthly_payment' => $monthlyPayment,
'remaining_months' => $remainingMonths,
];
}
/**
* The most recent balance for an account on or before today.
*/
private function latestBalance(string $accountId): int
{
return AccountBalance::query()
->where('account_id', $accountId)
->where('balance_date', '<=', now()->toDateString())
->orderByDesc('balance_date')
->value('balance') ?? 0;
}
}

Some files were not shown because too many files have changed in this diff Show More