Compare commits
No commits in common. "main" and "v0.1.18" have entirely different histories.
|
|
@ -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;
|
||||
// ...
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
---
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
../.agents/skills
|
||||
|
|
@ -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.
|
||||
---
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -6,9 +6,6 @@
|
|||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
},
|
||||
"sentry": {
|
||||
"url": "https://mcp.sentry.dev/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
.git
|
||||
.github
|
||||
.agents
|
||||
.claude
|
||||
.cursor
|
||||
.opencode
|
||||
.pi
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.production.example
|
||||
|
||||
node_modules
|
||||
vendor
|
||||
|
||||
storage/app/*
|
||||
storage/framework/cache/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/views/*
|
||||
storage/logs/*
|
||||
!storage/app/.gitignore
|
||||
!storage/framework/cache/.gitignore
|
||||
!storage/framework/sessions/.gitignore
|
||||
!storage/framework/views/.gitignore
|
||||
!storage/logs/.gitignore
|
||||
|
||||
Dockerfile
|
||||
Dockerfile.*
|
||||
docker-compose*.yml
|
||||
|
||||
screenshots
|
||||
62
.env.example
62
.env.example
|
|
@ -2,24 +2,12 @@ APP_NAME="Whisper Money"
|
|||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://whisper.money.localhost
|
||||
APP_URL=https://whisper.money.local
|
||||
|
||||
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=
|
||||
|
||||
# OAuth authorization server host for the MCP OAuth server (RFC 8414 issuer).
|
||||
# Set to a dedicated host OUTSIDE the PWA's manifest scope so mobile OAuth links
|
||||
# (e.g. ChatGPT on Android) open in the browser instead of being captured by the
|
||||
# installed PWA. Leave blank to use the app URL. Production: https://oauth.whisper.money
|
||||
MCP_AUTHORIZATION_SERVER=
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
|
|
@ -63,15 +51,12 @@ MAIL_HOST=127.0.0.1
|
|||
MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="no-reply@whisper.money"
|
||||
MAIL_FROM_ADDRESS="hi@whisper.money"
|
||||
MAIL_FROM_NAME="Whisper Money"
|
||||
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=
|
||||
|
||||
# Drip Emails (welcome, onboarding, promo codes, etc.)
|
||||
DRIP_EMAILS_ENABLED=true
|
||||
|
|
@ -79,29 +64,21 @@ 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
|
||||
|
||||
LEAD_REDIRECT_URL=https://google.es
|
||||
HIDE_AUTH_BUTTONS=false
|
||||
|
||||
# Optional. Defaults to true when unset, so existing installs keep public sign-ups.
|
||||
# Set to false to close public sign-ups while keeping login open (invite/admin-created
|
||||
# accounts only): the /register routes are not registered and every registration CTA
|
||||
# is hidden, while /login stays fully available.
|
||||
# REGISTRATION_ENABLED=false
|
||||
|
||||
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=
|
||||
|
|
@ -119,7 +96,6 @@ STRIPE_PRO_YEARLY_LOOKUP_KEY=whisper_pro_yearly
|
|||
SENTRY_LARAVEL_DSN=
|
||||
SENTRY_TRACES_SAMPLE_RATE=1.0
|
||||
SENTRY_PROFILES_SAMPLE_RATE=0
|
||||
SENTRY_ENABLE_LOGS=false
|
||||
|
||||
# Docker Service Ports (forwarded to host)
|
||||
FORWARD_DB_PORT=3307
|
||||
|
|
@ -139,31 +115,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=
|
||||
|
|
|
|||
|
|
@ -14,18 +14,9 @@ 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
|
||||
LOG_STACK=single
|
||||
LOG_LEVEL=error
|
||||
|
||||
# Database (MySQL)
|
||||
|
|
@ -45,13 +36,6 @@ SESSION_ENCRYPT=true
|
|||
# Queue
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
# Sentry Error Tracking
|
||||
SENTRY_LARAVEL_DSN=
|
||||
# SENTRY_RELEASE is injected by CI as whisper-money@<git-sha> during production builds.
|
||||
SENTRY_TRACES_SAMPLE_RATE=1.0
|
||||
SENTRY_PROFILES_SAMPLE_RATE=1.0
|
||||
SENTRY_ENABLE_LOGS=true
|
||||
|
||||
# Redis (internal to container - no configuration needed)
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
|
|
@ -59,11 +43,8 @@ REDIS_PORT=6379
|
|||
# Email - Resend (Recommended for production)
|
||||
MAIL_MAILER=resend
|
||||
RESEND_API_KEY=your-resend-api-key
|
||||
RESEND_LEADS_SEGMENT_ID=your-segment-id
|
||||
MAIL_FROM_ADDRESS=no-reply@your-domain.com
|
||||
MAIL_FROM_ADDRESS=hi@your-domain.com
|
||||
MAIL_FROM_NAME="Whisper Money"
|
||||
MAIL_DRIP_FROM_ADDRESS=hi@your-domain.com
|
||||
MAIL_DRIP_FROM_NAME="Álvaro and Víctor"
|
||||
|
||||
# Stripe (Optional - for subscriptions)
|
||||
STRIPE_KEY=
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
name: Setup Bun & Node Deps
|
||||
description: |
|
||||
Sets up Bun and installs Node dependencies, caching both the global
|
||||
Bun install cache and `node_modules/`. On a `node_modules/` cache hit
|
||||
`bun install` is skipped entirely.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Cache bun global cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: bun-${{ runner.os }}-
|
||||
|
||||
- name: Cache node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
|
||||
- name: Install Node Dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: bun install --frozen-lockfile
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
name: Setup PHP & Composer Deps
|
||||
description: |
|
||||
Sets up PHP 8.4 and installs Composer dependencies with two layers of cache:
|
||||
the global Composer cache directory and the resolved `vendor/` directory
|
||||
itself. On a `vendor/` cache hit we skip `composer install` entirely.
|
||||
|
||||
inputs:
|
||||
php-version:
|
||||
description: PHP version to install
|
||||
required: false
|
||||
default: '8.4'
|
||||
tools:
|
||||
description: Tools to install via shivammathur/setup-php
|
||||
required: false
|
||||
default: 'composer:v2'
|
||||
coverage:
|
||||
description: Coverage driver (xdebug, pcov, none)
|
||||
required: false
|
||||
default: 'none'
|
||||
composer-args:
|
||||
description: Extra args for `composer install` when running
|
||||
required: false
|
||||
default: '--no-interaction --prefer-dist --optimize-autoloader'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ inputs.php-version }}
|
||||
tools: ${{ inputs.tools }}
|
||||
coverage: ${{ inputs.coverage }}
|
||||
|
||||
- name: Get composer cache directory
|
||||
id: composer-cache
|
||||
shell: bash
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache composer global cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: composer-${{ runner.os }}-php${{ inputs.php-version }}-${{ hashFiles('composer.lock') }}
|
||||
restore-keys: composer-${{ runner.os }}-php${{ inputs.php-version }}-
|
||||
|
||||
- name: Cache vendor/
|
||||
id: vendor-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: vendor
|
||||
key: vendor-${{ runner.os }}-php${{ inputs.php-version }}-${{ hashFiles('composer.lock') }}
|
||||
|
||||
- name: Install PHP Dependencies
|
||||
if: steps.vendor-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: composer install ${{ inputs.composer-args }}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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` |
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -17,29 +17,6 @@ on:
|
|||
default: true
|
||||
|
||||
jobs:
|
||||
build-assets:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP & Composer Deps
|
||||
uses: ./.github/actions/setup-php-deps
|
||||
|
||||
- name: Setup Bun & Node Deps
|
||||
uses: ./.github/actions/setup-bun-deps
|
||||
|
||||
- name: Build Assets
|
||||
run: bun run build
|
||||
|
||||
- name: Upload Build Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-assets
|
||||
path: public/build
|
||||
retention-days: 1
|
||||
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
|
|
@ -55,8 +32,24 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP & Composer Deps
|
||||
uses: ./.github/actions/setup-php-deps
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
tools: composer:v2
|
||||
coverage: xdebug
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install Node Dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install Dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Build Assets
|
||||
run: bun run build
|
||||
|
||||
- name: Copy Environment File
|
||||
run: cp .env.example .env
|
||||
|
|
@ -64,16 +57,8 @@ 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
|
||||
# service runs as root which has CREATE on *.* so this Just Works
|
||||
# without extra grants. Performance suite is excluded because
|
||||
# concurrent load skews its timing-based assertions.
|
||||
run: ./vendor/bin/pest --exclude-testsuite=Browser,Performance --parallel --processes=4
|
||||
run: ./vendor/bin/pest --exclude-testsuite=Browser,Performance
|
||||
env:
|
||||
TESTCONTAINERS: false
|
||||
DB_CONNECTION: mysql
|
||||
|
|
@ -84,25 +69,8 @@ jobs:
|
|||
DB_PASSWORD: password
|
||||
|
||||
browser-tests:
|
||||
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: browser-tests-matrix
|
||||
steps:
|
||||
- name: Aggregate shard results
|
||||
run: |
|
||||
if [ "${{ needs.browser-tests-matrix.result }}" != "success" ]; then
|
||||
echo "One or more browser-tests shards failed: ${{ needs.browser-tests-matrix.result }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "All browser-tests shards passed."
|
||||
|
||||
browser-tests-matrix:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
|
|
@ -116,27 +84,28 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP & Composer Deps
|
||||
uses: ./.github/actions/setup-php-deps
|
||||
|
||||
- name: Setup Bun & Node Deps
|
||||
uses: ./.github/actions/setup-bun-deps
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: playwright-${{ runner.os }}-
|
||||
php-version: 8.4
|
||||
tools: composer:v2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install Node Dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Install Playwright system deps
|
||||
run: npx playwright install-deps chromium
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
- name: Install PHP Dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Build Assets
|
||||
run: bun run build
|
||||
|
|
@ -147,11 +116,8 @@ 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
|
||||
run: ./vendor/bin/pest --testsuite=Browser --ci
|
||||
env:
|
||||
TESTCONTAINERS: false
|
||||
DB_CONNECTION: mysql
|
||||
|
|
@ -161,109 +127,42 @@ jobs:
|
|||
DB_USERNAME: root
|
||||
DB_PASSWORD: password
|
||||
|
||||
update-browser-shards:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.build_only == false
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: password
|
||||
MYSQL_DATABASE: testing
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP & Composer Deps
|
||||
uses: ./.github/actions/setup-php-deps
|
||||
|
||||
- name: Setup Bun & Node Deps
|
||||
uses: ./.github/actions/setup-bun-deps
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: playwright-${{ runner.os }}-
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Install Playwright system deps
|
||||
run: npx playwright install-deps chromium
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
|
||||
- name: Build Assets
|
||||
run: bun run build
|
||||
|
||||
- name: Copy Environment File
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Generate Application Key
|
||||
run: php artisan key:generate
|
||||
|
||||
- name: Update Browser Shards
|
||||
run: ./vendor/bin/pest --testsuite=Browser --ci --update-shards
|
||||
env:
|
||||
TESTCONTAINERS: false
|
||||
DB_CONNECTION: mysql
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_PORT: 3306
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: root
|
||||
DB_PASSWORD: password
|
||||
|
||||
- name: Upload Browser Shards
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: browser-shards
|
||||
path: tests/.pest/shards.json
|
||||
retention-days: 1
|
||||
|
||||
static-analysis:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP & Composer Deps
|
||||
uses: ./.github/actions/setup-php-deps
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
composer-args: '-q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist'
|
||||
php-version: '8.4'
|
||||
|
||||
- name: Install PHP Dependencies
|
||||
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||
|
||||
- name: Run PHPStan
|
||||
run: vendor/bin/phpstan analyse --memory-limit=512M --no-progress
|
||||
|
||||
linter:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP & Composer Deps
|
||||
uses: ./.github/actions/setup-php-deps
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
composer-args: '-q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist'
|
||||
php-version: '8.4'
|
||||
|
||||
- name: Setup Bun & Node Deps
|
||||
uses: ./.github/actions/setup-bun-deps
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Cache Pint results
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .pint.cache
|
||||
key: pint-${{ runner.os }}-${{ hashFiles('composer.lock', 'pint.json') }}
|
||||
restore-keys: pint-${{ runner.os }}-
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||
bun install --frozen-lockfile
|
||||
|
||||
- name: Run Pint
|
||||
run: vendor/bin/pint --test --parallel --cache-file=.pint.cache
|
||||
run: vendor/bin/pint --test
|
||||
|
||||
- name: Format Frontend
|
||||
run: bun run format
|
||||
|
|
@ -271,44 +170,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:
|
||||
|
|
@ -324,8 +188,23 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP & Composer Deps
|
||||
uses: ./.github/actions/setup-php-deps
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
tools: composer:v2
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install Node Dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install Dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Build Assets
|
||||
run: bun run build
|
||||
|
||||
- name: Copy Environment File
|
||||
run: cp .env.example .env
|
||||
|
|
@ -344,85 +223,19 @@ jobs:
|
|||
DB_USERNAME: root
|
||||
DB_PASSWORD: password
|
||||
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
code: ${{ steps.filter.outputs.code }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
code:
|
||||
- 'app/**'
|
||||
- 'bootstrap/**'
|
||||
- 'config/**'
|
||||
- 'database/**'
|
||||
- 'public/**'
|
||||
- 'resources/**'
|
||||
- 'routes/**'
|
||||
- 'storage/**'
|
||||
- 'artisan'
|
||||
- 'composer.json'
|
||||
- 'composer.lock'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'bun.lock'
|
||||
- 'bun.lockb'
|
||||
- 'vite.config.*'
|
||||
- 'tsconfig*.json'
|
||||
- 'eslint.config.*'
|
||||
- '.prettierrc*'
|
||||
- 'phpstan.neon*'
|
||||
- 'phpunit.xml*'
|
||||
- 'pint.json'
|
||||
- 'Dockerfile'
|
||||
- 'Dockerfile.*'
|
||||
- 'docker/**'
|
||||
- '.dockerignore'
|
||||
- '.env.example'
|
||||
- '.env.production.example'
|
||||
- 'docker-compose.production.yml'
|
||||
- 'docker-compose.production.local.yml'
|
||||
- 'templates/coolify/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
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 }}
|
||||
needs: [tests, linter, static-analysis, performance-tests]
|
||||
if: (github.ref == 'refs/heads/main' && github.event_name == 'push') || github.event_name == 'workflow_dispatch'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Sentry CLI
|
||||
run: curl -sL https://sentry.io/get-cli/ | bash
|
||||
|
||||
- name: Create Sentry release
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: |
|
||||
if ! sentry-cli releases info "$SENTRY_RELEASE" >/dev/null 2>&1; then
|
||||
sentry-cli releases new "$SENTRY_RELEASE"
|
||||
fi
|
||||
|
||||
sentry-cli releases set-commits "$SENTRY_RELEASE" --auto --ignore-missing
|
||||
sentry-cli releases finalize "$SENTRY_RELEASE"
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
|
@ -437,161 +250,63 @@ jobs:
|
|||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=sha,prefix=,suffix=-development
|
||||
type=raw,value=development
|
||||
|
||||
- name: Extract package version
|
||||
id: package-version
|
||||
run: node -e "console.log('version=' + require('./package.json').version)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Extract production metadata
|
||||
id: production-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest
|
||||
type=raw,value=production
|
||||
type=raw,value=v${{ steps.package-version.outputs.version }}-production
|
||||
|
||||
- name: Build and push development image
|
||||
id: development-image
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
timeout-minutes: 20
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
SENTRY_RELEASE=${{ env.SENTRY_RELEASE }}
|
||||
cache-from: type=gha
|
||||
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:
|
||||
context: .
|
||||
file: Dockerfile.production
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.production-meta.outputs.tags }}
|
||||
labels: ${{ steps.production-meta.outputs.labels }}
|
||||
build-args: |
|
||||
SENTRY_RELEASE=${{ env.SENTRY_RELEASE }}
|
||||
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'
|
||||
env:
|
||||
SENTRY_RELEASE: whisper-money@${{ github.sha }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [tests, linter, static-analysis, performance-tests]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
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=,suffix=-development
|
||||
type=raw,value=development
|
||||
|
||||
- name: Extract production metadata
|
||||
id: production-meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest
|
||||
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: Trigger deployment
|
||||
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..."
|
||||
|
||||
- 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
|
||||
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")
|
||||
|
||||
- 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 }}
|
||||
run: |
|
||||
while IFS= read -r tag; do
|
||||
[ -n "$tag" ] || continue
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
docker buildx imagetools create \
|
||||
--tag "$tag" \
|
||||
"$IMAGE@$AMD64_DIGEST" \
|
||||
"$IMAGE@$ARM64_DIGEST"
|
||||
done <<< "$TAGS"
|
||||
echo "Response: $body"
|
||||
echo "HTTP Status: $http_code"
|
||||
|
||||
if [ "$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=$((attempt * 10))
|
||||
echo "Retrying in ${delay}s..."
|
||||
sleep $delay
|
||||
fi
|
||||
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
echo "All $max_attempts attempts failed. Giving up."
|
||||
exit 1
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 8 * * 1" # Mondays 08:00 UTC = 10:00 Madrid (CEST) / 09:00 (CET)
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
increment:
|
||||
description: "Version increment"
|
||||
required: true
|
||||
default: patch
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
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
|
||||
|
||||
- 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: |
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head "${{ steps.branch.outputs.name }}" \
|
||||
--title "chore: release v${{ steps.version.outputs.value }}" \
|
||||
--body "Automated release PR for **v${{ steps.version.outputs.value }}**.\n\nTag \`v${{ steps.version.outputs.value }}\` and the GitHub release have already been published. Merge this PR to land the version bump and CHANGELOG on \`main\`."
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
/.phpunit.cache
|
||||
/bootstrap/ssr
|
||||
/node_modules
|
||||
node_modules/
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
|
|
@ -29,7 +28,7 @@ yarn-error.log
|
|||
/.vscode
|
||||
/.zed
|
||||
.php-cs-fixer.cache
|
||||
/docker/caddy/certs/*.pem
|
||||
/docker/caddy/certs/*-key.pem
|
||||
.claude/settings.local.json
|
||||
.php-version
|
||||
.pint.cache
|
||||
/.playwright-mcp
|
||||
|
|
|
|||
|
|
@ -6,10 +6,6 @@
|
|||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
},
|
||||
"sentry": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.sentry.dev/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from './mcps/main.ts';
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"name": "pi-mcp-bridge",
|
||||
"private": true,
|
||||
"description": "Project-local Pi extension that loads MCP servers from .mcp.json or opencode.json",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@sinclair/typebox": "^0.34.49",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
---
|
||||
description: Pick or fix a Sentry issue end-to-end, then PR and watch CI
|
||||
argument-hint: "[issue-id-or-url]"
|
||||
---
|
||||
Run Sentry issue repair workflow for this project only.
|
||||
|
||||
Input: `$ARGUMENTS`
|
||||
|
||||
Goal:
|
||||
1. Use provided Sentry issue id or URL. If none provided, choose the highest-impact unresolved issue using frequency, affected users, recency, and production impact.
|
||||
2. Create/switch to git branch named exactly after the Sentry short issue id.
|
||||
3. Investigate root cause, implement fix, and add/update tests.
|
||||
4. Open PR and watch CI until green.
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- Prefer small surgical fix. No dependency changes without approval.
|
||||
|
||||
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.
|
||||
- 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`.
|
||||
- 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`.
|
||||
4. Fix:
|
||||
- Read nearby code and conventions first.
|
||||
- Implement minimal fix.
|
||||
- Add regression test covering Sentry failure path.
|
||||
5. Verify:
|
||||
- Run targeted test command, e.g. `php artisan test --compact --filter=<test-or-class>`.
|
||||
- Run lint/format commands required by touched files.
|
||||
- If failures occur, fix and rerun until pass.
|
||||
6. PR:
|
||||
- Commit changes with concise conventional commit.
|
||||
- Push branch.
|
||||
- Create PR with `gh pr create`, including Sentry issue link, root cause, fix summary, and verification commands.
|
||||
- Get PR number from `gh pr view --json number --jq .number` if needed.
|
||||
- Watch CI every 10 seconds with `gh pr checks <number> --watch --fail-fast --interval 10`.
|
||||
- If CI fails, inspect logs, fix, commit/push, and watch again until green.
|
||||
|
||||
Output when done:
|
||||
- Issue id + Sentry URL
|
||||
- Branch name
|
||||
- Root cause
|
||||
- Fix summary
|
||||
- Tests/commands run
|
||||
- PR URL
|
||||
- CI status
|
||||
|
|
@ -1,13 +1,7 @@
|
|||
{
|
||||
"hooks": {
|
||||
"after:bump": "node scripts/enrich-changelog.js && git add CHANGELOG.md"
|
||||
},
|
||||
"git": {
|
||||
"commitMessage": "chore: release v${version}",
|
||||
"tagName": "v${version}",
|
||||
"requireBranch": false,
|
||||
"requireCleanWorkingDir": true,
|
||||
"push": true
|
||||
"tagName": "v${version}"
|
||||
},
|
||||
"github": {
|
||||
"release": true,
|
||||
|
|
|
|||
88
AGENTS.md
88
AGENTS.md
|
|
@ -1,12 +1,3 @@
|
|||
Terse like caveman. Technical substance exact. Only fluff die.
|
||||
Drop: articles, filler (just/really/basically), pleasantries, hedging.
|
||||
Fragments OK. Short synonyms. Code unchanged.
|
||||
Pattern: [thing] [action] [reason]. [next step].
|
||||
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift.
|
||||
Code/commits/PRs: normal. Off: "stop caveman" / "normal mode".
|
||||
|
||||
- After creating a PR use `gh pr checks 123 --watch --fail-fast` to check CI every 10 sec, if something fails, fix it, and update the pr.
|
||||
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
|
|
@ -18,15 +9,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 +35,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 <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions React with Inertia, React pages, React forms, or React navigation.
|
||||
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||
- `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 +75,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
|
||||
|
||||
|
|
@ -168,7 +153,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 +165,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 +186,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 +223,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 +282,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 +291,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
|
||||
|
|
|
|||
395
CHANGELOG.md
395
CHANGELOG.md
|
|
@ -1,400 +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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **banking:** dedup EnableBanking transactions by deterministic fingerprint ([#390](https://github.com/whisper-money/whisper-money/issues/390)) ([d9204bb](https://github.com/whisper-money/whisper-money/commit/d9204bb3d611c382493e45691d9ec963a73bf454))
|
||||
* **banking:** treat Indexa Capital performance 404 as empty data ([#386](https://github.com/whisper-money/whisper-money/issues/386)) ([06e7eed](https://github.com/whisper-money/whisper-money/commit/06e7eed4e20796accf0555659baeffb481e925bd))
|
||||
* **budget:** show today marker ([#411](https://github.com/whisper-money/whisper-money/issues/411)) ([933dfde](https://github.com/whisper-money/whisper-money/commit/933dfdeb1b32ffba04850b973d9c3d4681262053))
|
||||
* **connections:** show expired reconnect ([#407](https://github.com/whisper-money/whisper-money/issues/407)) ([d2e00f1](https://github.com/whisper-money/whisper-money/commit/d2e00f14e5302ccd40f620733479fc7a7b410699))
|
||||
* keep lead invite command aliases ([#406](https://github.com/whisper-money/whisper-money/issues/406)) ([11f989d](https://github.com/whisper-money/whisper-money/commit/11f989d03af290f9ee32bc6e46fad327dd2c1e03))
|
||||
* **notifications:** skip mail dispatch when recipient email is invalid ([#387](https://github.com/whisper-money/whisper-money/issues/387)) ([d140b4f](https://github.com/whisper-money/whisper-money/commit/d140b4fd4cd5402b464c203d619c231db9672ef7))
|
||||
* **sentry:** ignore postMessage clone noise ([#373](https://github.com/whisper-money/whisper-money/issues/373)) ([6335287](https://github.com/whisper-money/whisper-money/commit/63352877654bc880e09e36d0a3efada527961d82))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Coinbase banking integration ([#388](https://github.com/whisper-money/whisper-money/issues/388)) ([e71a743](https://github.com/whisper-money/whisper-money/commit/e71a743a0a6e3a1a6bde5c1bc29df94555f74bd1))
|
||||
* **import:** calculate balances from transactions ([#403](https://github.com/whisper-money/whisper-money/issues/403)) ([66ff427](https://github.com/whisper-money/whisper-money/commit/66ff427481dfe0e00cf632e3f0f1caef33238636))
|
||||
|
||||
## [0.2.1](https://github.com/whisper-money/whisper-money/compare/v0.2.0...v0.2.1) (2026-05-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add yearly budget period ([#384](https://github.com/whisper-money/whisper-money/issues/384)) ([f8f3b06](https://github.com/whisper-money/whisper-money/commit/f8f3b06))
|
||||
* Add labels to automation rules ([#379](https://github.com/whisper-money/whisper-money/issues/379)) ([5b8e7e8](https://github.com/whisper-money/whisper-money/commit/5b8e7e8))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fix exchange rate cache race (PHP-LARAVEL-1V) ([#383](https://github.com/whisper-money/whisper-money/issues/383)) ([c3dcbb4](https://github.com/whisper-money/whisper-money/commit/c3dcbb4))
|
||||
* Fix cashflow null category rows ([#382](https://github.com/whisper-money/whisper-money/issues/382)) ([30cc4da](https://github.com/whisper-money/whisper-money/commit/30cc4da))
|
||||
* Fix browser translation crash (PHP-LARAVEL-1S) ([#381](https://github.com/whisper-money/whisper-money/issues/381)) ([e635fda](https://github.com/whisper-money/whisper-money/commit/e635fda))
|
||||
* Fix cashflow multi-currency totals ([#380](https://github.com/whisper-money/whisper-money/issues/380)) ([4e03996](https://github.com/whisper-money/whisper-money/commit/4e03996))
|
||||
* Fix service worker registration rejection ([#376](https://github.com/whisper-money/whisper-money/issues/376)) ([3526e5f](https://github.com/whisper-money/whisper-money/commit/3526e5f))
|
||||
* Recover from stale Vite chunks ([#374](https://github.com/whisper-money/whisper-money/issues/374)) ([69610c5](https://github.com/whisper-money/whisper-money/commit/69610c5))
|
||||
* **sentry:** ignore postMessage clone noise ([#373](https://github.com/whisper-money/whisper-money/issues/373)) ([6335287](https://github.com/whisper-money/whisper-money/commit/6335287))
|
||||
* Fix Sentry transaction and dashboard crashes ([#372](https://github.com/whisper-money/whisper-money/issues/372)) ([718cfa9](https://github.com/whisper-money/whisper-money/commit/718cfa9))
|
||||
* Fix Sentry release commit detection in image build ([#371](https://github.com/whisper-money/whisper-money/issues/371)) ([f4ab4a1](https://github.com/whisper-money/whisper-money/commit/f4ab4a1))
|
||||
* Prevent cached cashflow analytics responses ([#368](https://github.com/whisper-money/whisper-money/issues/368)) ([97df059](https://github.com/whisper-money/whisper-money/commit/97df059))
|
||||
* Fix duplicate category name validation ([#364](https://github.com/whisper-money/whisper-money/issues/364)) ([e3c2d2f](https://github.com/whisper-money/whisper-money/commit/e3c2d2f))
|
||||
|
||||
|
||||
### Chores
|
||||
|
||||
* Add sentry issue slash command ([#375](https://github.com/whisper-money/whisper-money/issues/375)) ([c929c1f](https://github.com/whisper-money/whisper-money/commit/c929c1f))
|
||||
* Update worktree script ([#366](https://github.com/whisper-money/whisper-money/issues/366)) ([360a38a](https://github.com/whisper-money/whisper-money/commit/360a38a))
|
||||
* Speed up PR CI browser path ([#365](https://github.com/whisper-money/whisper-money/issues/365)) ([e36d6f3](https://github.com/whisper-money/whisper-money/commit/e36d6f3))
|
||||
|
||||
# [0.2.0](https://github.com/whisper-money/whisper-money/compare/v0.1.20...v0.2.0) (2026-05-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **banking:** clamp linkedDateFrom to today on EnableBanking sync ([#343](https://github.com/whisper-money/whisper-money/issues/343)) ([f6c2057](https://github.com/whisper-money/whisper-money/commit/f6c20576b5dd6a98cb69c860825459fe010e2164))
|
||||
* **budgets:** remove Custom period type to fix duplicate-key crash ([#355](https://github.com/whisper-money/whisper-money/issues/355)) ([22043ce](https://github.com/whisper-money/whisper-money/commit/22043ced29e80486bcc3bb025952fda0f0b1f537))
|
||||
* **dashboard:** avoid month overflow in real estate projection ([#340](https://github.com/whisper-money/whisper-money/issues/340)) ([8f42496](https://github.com/whisper-money/whisper-money/commit/8f42496a5f6cd655828df7c49f358ad61d7e8002))
|
||||
* include production Dockerfile in deploy filter ([#350](https://github.com/whisper-money/whisper-money/issues/350)) ([21b5692](https://github.com/whisper-money/whisper-money/commit/21b5692174f2cf23d44a93e26f7b39d21edfe383))
|
||||
* **onboarding:** guard window access in SSR ([#351](https://github.com/whisper-money/whisper-money/issues/351)) ([b1709b7](https://github.com/whisper-money/whisper-money/commit/b1709b714e5e5d591351db51f7d2b31fb201fe74))
|
||||
* **real-estate:** compound annual revaluation monthly ([#337](https://github.com/whisper-money/whisper-money/issues/337)) ([13f741a](https://github.com/whisper-money/whisper-money/commit/13f741aaed38681571c5950da844f44309306858))
|
||||
* unblock onboarding after sync failure ([#346](https://github.com/whisper-money/whisper-money/issues/346)) ([70f3897](https://github.com/whisper-money/whisper-money/commit/70f3897b5534940c4be1dfdce3b4ce8978a882b9))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **accounts:** show projection on real estate chart ([#338](https://github.com/whisper-money/whisper-money/issues/338)) ([0f2300b](https://github.com/whisper-money/whisper-money/commit/0f2300bf3e420576893758117ed5583b39f656d7))
|
||||
* **banking:** back off scheduler when EnableBanking returns 429 ([#352](https://github.com/whisper-money/whisper-money/issues/352)) ([f800847](https://github.com/whisper-money/whisper-money/commit/f80084759133a5e00fc997602266575d3806dfaa))
|
||||
* **leads:** cohort-based launch invitations with per-user Stripe coupons ([#333](https://github.com/whisper-money/whisper-money/issues/333)) ([ab3d6e9](https://github.com/whisper-money/whisper-money/commit/ab3d6e9fcaeccf3b57027c26904460e788c8df3e))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **resend:** default sync-leads to last 24h window ([#354](https://github.com/whisper-money/whisper-money/issues/354)) ([e387c03](https://github.com/whisper-money/whisper-money/commit/e387c038ca6e5e0ea3f757e28c52125ea20ba198))
|
||||
|
||||
## [0.1.20](https://github.com/whisper-money/whisper-money/compare/v0.1.19...v0.1.20) (2026-04-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **accounts:** use chart color scheme for real estate sparkline and balance charts ([#247](https://github.com/whisper-money/whisper-money/issues/247)) ([8b71115](https://github.com/whisper-money/whisper-money/commit/8b71115afc0f46ec1867e7030bffc87cad481a10))
|
||||
* add missing port to frontend Bugsink DSN ([#260](https://github.com/whisper-money/whisper-money/issues/260)) ([6ce5b12](https://github.com/whisper-money/whisper-money/commit/6ce5b123ce9b58ae7ec660d8cbcd005fb1748e35))
|
||||
* align onboarding account types with current asset support ([#273](https://github.com/whisper-money/whisper-money/issues/273)) ([80274e0](https://github.com/whisper-money/whisper-money/commit/80274e03a8e697509ddbd0ec3e7a4e9d5d752d10))
|
||||
* **auth:** allow forced registration ([#307](https://github.com/whisper-money/whisper-money/issues/307)) ([75736f3](https://github.com/whisper-money/whisper-money/commit/75736f3e59966e6821f436d4aac7f45e4111e5da))
|
||||
* avoid iOS PWA status bar overlap ([#281](https://github.com/whisper-money/whisper-money/issues/281)) ([80b6668](https://github.com/whisper-money/whisper-money/commit/80b666836c9ad106c526eb45c82046af953c0342))
|
||||
* **banking:** retry failed sync connections and log every sync attempt ([#251](https://github.com/whisper-money/whisper-money/issues/251)) ([f3b5929](https://github.com/whisper-money/whisper-money/commit/f3b5929ecc2ca4d093e645ff996fc47b63440e17))
|
||||
* batch Pennant feature flag queries to avoid N+1 selects ([#244](https://github.com/whisper-money/whisper-money/issues/244)) ([8ac6ed4](https://github.com/whisper-money/whisper-money/commit/8ac6ed4d83e14eaab9fe8215247e091fab8258c3)), closes [#241](https://github.com/whisper-money/whisper-money/issues/241)
|
||||
* **budgets:** make budget assignment idempotent ([#303](https://github.com/whisper-money/whisper-money/issues/303)) ([b1ceda6](https://github.com/whisper-money/whisper-money/commit/b1ceda61f93d1bb385060b7ffee35fb56fd41962))
|
||||
* **budgets:** retry assignment deadlocks ([#304](https://github.com/whisper-money/whisper-money/issues/304)) ([45e311e](https://github.com/whisper-money/whisper-money/commit/45e311e17baaa510a4309724937c5b18ded42631))
|
||||
* **cashflow:** exclude transfer categories from sankey ([#235](https://github.com/whisper-money/whisper-money/issues/235)) ([debb47f](https://github.com/whisper-money/whisper-money/commit/debb47f6af2808669a319a696d9a81036ca7b961))
|
||||
* **cashflow:** net transfer categories in sankey ([#257](https://github.com/whisper-money/whisper-money/issues/257)) ([83f7e83](https://github.com/whisper-money/whisper-money/commit/83f7e83a134db2fe98f4b3ba75f173b7e0f44e44))
|
||||
* **cashflow:** read period from server props instead of window ([#302](https://github.com/whisper-money/whisper-money/issues/302)) ([22952c4](https://github.com/whisper-money/whisper-money/commit/22952c4e75cfbe933b42c91da826ff0e33e472e3))
|
||||
* **chart:** hide tooltip on scroll with opacity fade ([#320](https://github.com/whisper-money/whisper-money/issues/320)) ([38e1976](https://github.com/whisper-money/whisper-money/commit/38e1976270b3afafac93d02a5586c508762e25af))
|
||||
* **chart:** tooltip escapes overflow, truncates long labels ([#317](https://github.com/whisper-money/whisper-money/issues/317)) ([e4d2ade](https://github.com/whisper-money/whisper-money/commit/e4d2ade92f4c532fa040a9b98e2fcee2ba5cc3b9))
|
||||
* **ci:** order sentry deploy after build ([#309](https://github.com/whisper-money/whisper-money/issues/309)) ([bfe1af3](https://github.com/whisper-money/whisper-money/commit/bfe1af3c839e3370d5b6132efdaaad5a6b9983a3))
|
||||
* **ci:** skip outdated production deploys ([b36197e](https://github.com/whisper-money/whisper-money/commit/b36197e76bca7b73cc50f4f53775974326cae264))
|
||||
* clarify account creation modal copy ([#274](https://github.com/whisper-money/whisper-money/issues/274)) ([dafc58f](https://github.com/whisper-money/whisper-money/commit/dafc58f49f0a832a45bbd3f02fd39340e575a4d7))
|
||||
* clarify mobile settings navigation ([#272](https://github.com/whisper-money/whisper-money/issues/272)) ([62ab1b3](https://github.com/whisper-money/whisper-money/commit/62ab1b38db8fc03e4e3172cc31676442b850deaf))
|
||||
* **dashboard:** dismiss account card tooltip when tapping outside ([#318](https://github.com/whisper-money/whisper-money/issues/318)) ([753002f](https://github.com/whisper-money/whisper-money/commit/753002f930f4abe8c8025bac7f28609d1694152c))
|
||||
* **dashboard:** treat loans as debt in net worth ([#238](https://github.com/whisper-money/whisper-money/issues/238)) ([f140b5d](https://github.com/whisper-money/whisper-money/commit/f140b5df7f2188dde8d278eca47a4e8eaa431f86))
|
||||
* default account charts to user currency ([#271](https://github.com/whisper-money/whisper-money/issues/271)) ([38cf672](https://github.com/whisper-money/whisper-money/commit/38cf672c8e9ba24e8f8f956e2b19a2c05c98064a))
|
||||
* default to standard onboarding option ([#276](https://github.com/whisper-money/whisper-money/issues/276)) ([d91d9d3](https://github.com/whisper-money/whisper-money/commit/d91d9d3b3eb2ac7c6c9deed2ef2454835daf5d5a))
|
||||
* **demo-reset:** use renamed 'ING Direct' bank ([#301](https://github.com/whisper-money/whisper-money/issues/301)) ([cfa54a2](https://github.com/whisper-money/whisper-money/commit/cfa54a2d9dc9b8031d18528b51bde933ed501729))
|
||||
* **docker:** ensure www-data owns storage after artisan commands ([#329](https://github.com/whisper-money/whisper-money/issues/329)) ([0eca002](https://github.com/whisper-money/whisper-money/commit/0eca00285699ca67dccd6c7ab8ec5af853a951fc))
|
||||
* expose pi mcp extension as mcps.ts ([#315](https://github.com/whisper-money/whisper-money/issues/315)) ([c7cfa10](https://github.com/whisper-money/whisper-money/commit/c7cfa1011764be700687da5e499de1fde3445e65))
|
||||
* **i18n:** add missing Spanish translations for mortgage UI strings ([0a535fb](https://github.com/whisper-money/whisper-money/commit/0a535fbf4729afc4bf0c791faddbfc71397c01ef))
|
||||
* **i18n:** translate Unknown Income/Expense and other missing ES strings ([#331](https://github.com/whisper-money/whisper-money/issues/331)) ([79075db](https://github.com/whisper-money/whisper-money/commit/79075dbcdf2003373483afd396d2b4cb4b415f6a))
|
||||
* keep iOS content below the notch ([#280](https://github.com/whisper-money/whisper-money/issues/280)) ([b505d68](https://github.com/whisper-money/whisper-money/commit/b505d68ef0ac4d52ee94a85b4e6b113c9d8d35c9))
|
||||
* keep iOS popovers below the notch ([#282](https://github.com/whisper-money/whisper-money/issues/282)) ([ea9956f](https://github.com/whisper-money/whisper-money/commit/ea9956f21da3f7498bf947f539c6b31fa844fe96))
|
||||
* limit bank sync emails to one per day ([#290](https://github.com/whisper-money/whisper-money/issues/290)) ([552aa59](https://github.com/whisper-money/whisper-money/commit/552aa59aaf5e476c81d81483ca9118f872730d2e))
|
||||
* **loans:** project monthly balances from actual entries instead of original params ([#259](https://github.com/whisper-money/whisper-money/issues/259)) ([7e95828](https://github.com/whisper-money/whisper-money/commit/7e958284e3944a9bf3dfae08524f81afbca4a7da))
|
||||
* make transaction sync email use default sender ([#265](https://github.com/whisper-money/whisper-money/issues/265)) ([7be0fe0](https://github.com/whisper-money/whisper-money/commit/7be0fe012041283df651c1fbce7b3f69102a500f))
|
||||
* **open-banking:** respect local email hours ([#306](https://github.com/whisper-money/whisper-money/issues/306)) ([fbffdd3](https://github.com/whisper-money/whisper-money/commit/fbffdd3f3c16ae075bb2d779e22d1b4e82a792e9))
|
||||
* **open-banking:** skip silent sync emails ([#295](https://github.com/whisper-money/whisper-money/issues/295)) ([473ac03](https://github.com/whisper-money/whisper-money/commit/473ac03088b3ad6e09c32344e0b4ca5f1db489ea))
|
||||
* **open-banking:** sort bank sync email data ([#292](https://github.com/whisper-money/whisper-money/issues/292)) ([c90e816](https://github.com/whisper-money/whisper-money/commit/c90e8166bfc94f1af7aab2197edd87d68eb9e1b9))
|
||||
* **open-banking:** suppress first sync email ([#310](https://github.com/whisper-money/whisper-money/issues/310)) ([16675f6](https://github.com/whisper-money/whisper-money/commit/16675f6518ec2e652b711c483d28d4b22792abd6))
|
||||
* preserve cents in chart amounts ([#270](https://github.com/whisper-money/whisper-money/issues/270)) ([0735ee6](https://github.com/whisper-money/whisper-money/commit/0735ee6d697bd8d46044a223bc1061b8742f035e))
|
||||
* **pricing:** update final release prices ([#288](https://github.com/whisper-money/whisper-money/issues/288)) ([319ca75](https://github.com/whisper-money/whisper-money/commit/319ca758e1e9869445512e9311b3d26a4197291f))
|
||||
* prioritize exact bank search matches ([#267](https://github.com/whisper-money/whisper-money/issues/267)) ([1e20361](https://github.com/whisper-money/whisper-money/commit/1e2036110fe05d564069bcc57ffadec4fb8a8147))
|
||||
* reorder signed names in mail templates ([#266](https://github.com/whisper-money/whisper-money/issues/266)) ([fec9373](https://github.com/whisper-money/whisper-money/commit/fec93734c0dd2d618c00e99247506d314b9b10e7))
|
||||
* route new PWA guests to signup ([#313](https://github.com/whisper-money/whisper-money/issues/313)) ([905edeb](https://github.com/whisper-money/whisper-money/commit/905edeb4a249cf71a9fccd7815f14fbadc20c884))
|
||||
* **schedule:** remove stale horizon snapshot ([#293](https://github.com/whisper-money/whisper-money/issues/293)) ([b438a1c](https://github.com/whisper-money/whisper-money/commit/b438a1c73bfb388c784764dbe08b2274c40126ed))
|
||||
* split drip and default email senders ([#263](https://github.com/whisper-money/whisper-money/issues/263)) ([ce5692c](https://github.com/whisper-money/whisper-money/commit/ce5692cb3036ec47c4f82ae57aaadfd58e6c14a4))
|
||||
* **user:** persist detected timezones ([#296](https://github.com/whisper-money/whisper-money/issues/296)) ([fde5405](https://github.com/whisper-money/whisper-money/commit/fde5405777250f71cdcc1b45fae73fdb64cd7496))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **accounts:** add loan amortization projections for loan accounts ([#246](https://github.com/whisper-money/whisper-money/issues/246)) ([bb65bdc](https://github.com/whisper-money/whisper-money/commit/bb65bdc16e2f0952ec7508dbce418d0155715077))
|
||||
* **accounts:** add market value and annual revaluation to real estate accounts ([#245](https://github.com/whisper-money/whisper-money/issues/245)) ([fa11dc7](https://github.com/whisper-money/whisper-money/commit/fa11dc78e0c60e310a13708edfd35926f1435a0b))
|
||||
* **accounts:** add real estate asset tracking ([#241](https://github.com/whisper-money/whisper-money/issues/241)) ([395c4ad](https://github.com/whisper-money/whisper-money/commit/395c4ad2c34b43b341a675ce5526edf2a3d03cd0))
|
||||
* **accounts:** add today marker on projected balance chart ([#321](https://github.com/whisper-money/whisper-money/issues/321)) ([4b145e2](https://github.com/whisper-money/whisper-money/commit/4b145e230b5a19a25b585260b05f2cc2c19fe066))
|
||||
* **accounts:** allow setting initial balance when creating balance-tracking accounts ([#239](https://github.com/whisper-money/whisper-money/issues/239)) ([7a05621](https://github.com/whisper-money/whisper-money/commit/7a056213cf6a29eab0b2416f69fd7dfa9ab1061d))
|
||||
* **accounts:** merge real estate accounts with linked mortgages in UI ([#248](https://github.com/whisper-money/whisper-money/issues/248)) ([6e97635](https://github.com/whisper-money/whisper-money/commit/6e976354ba2e673d5b183bacc3e9a896937ee54f))
|
||||
* **accounts:** show mortgage data and equity on real estate account page ([#243](https://github.com/whisper-money/whisper-money/issues/243)) ([9732432](https://github.com/whisper-money/whisper-money/commit/973243277a512b40f46d48cae557d240924fe2cb))
|
||||
* add appearance shortcut to user menu ([#269](https://github.com/whisper-money/whisper-money/issues/269)) ([3acb277](https://github.com/whisper-money/whisper-money/commit/3acb277fb5838c8538b989aa0ba7a8e209ac917f))
|
||||
* **billing:** apply Stripe tax rates to subscriptions ([#325](https://github.com/whisper-money/whisper-money/issues/325)) ([74cbdd4](https://github.com/whisper-money/whisper-money/commit/74cbdd42efea0e8884639b049a5de7138489fad2))
|
||||
* **cashflow:** show tracked transfers in Sankey diagram ([#237](https://github.com/whisper-money/whisper-money/issues/237)) ([6dda5f5](https://github.com/whisper-money/whisper-money/commit/6dda5f56ade8d669b9c0843d4980c2d76c9dc614)), closes [hi#level](https://github.com/hi/issues/level)
|
||||
* **cashflow:** track transfer categories in trends ([#236](https://github.com/whisper-money/whisper-money/issues/236)) ([272dac1](https://github.com/whisper-money/whisper-money/commit/272dac14b82b6863af6eddf88dc54e0fb408c9f1))
|
||||
* **dashboard:** merge real estate accounts with linked mortgages on dashboard ([752176e](https://github.com/whisper-money/whisper-money/commit/752176e80d67241ab4566d3ced0a7abe8a987b69))
|
||||
* **landing:** add signed auth links ([#312](https://github.com/whisper-money/whisper-money/issues/312)) ([240fcf1](https://github.com/whisper-money/whisper-money/commit/240fcf17030c605ed5daaa3fffa77018e20968c5))
|
||||
* link loans to existing properties ([#275](https://github.com/whisper-money/whisper-money/issues/275)) ([a7c1bd3](https://github.com/whisper-money/whisper-money/commit/a7c1bd35ef058f6ef468cdd96a3b9e3a9be89de1))
|
||||
* **loans:** backfill historical balances on loan creation ([#322](https://github.com/whisper-money/whisper-money/issues/322)) ([5b1d059](https://github.com/whisper-money/whisper-money/commit/5b1d059e020f7aa12c3502b51b174d0615a820e1))
|
||||
* **open-banking:** remove feature flag gating ([#297](https://github.com/whisper-money/whisper-money/issues/297)) ([244344e](https://github.com/whisper-money/whisper-money/commit/244344e953033b12a948c5f9d85d7db4639bba1d))
|
||||
* **real-estate:** auto-calculate revaluation % and generate historical balances ([#253](https://github.com/whisper-money/whisper-money/issues/253)) ([094fb1b](https://github.com/whisper-money/whisper-money/commit/094fb1b7446ca57e32e00018b78ddd645eeea3a3))
|
||||
* resend verification emails to unverified leads ([#287](https://github.com/whisper-money/whisper-money/issues/287)) ([5b78509](https://github.com/whisper-money/whisper-money/commit/5b7850958882988d80080eaa456e599007b974c8))
|
||||
* selective retry of failed lead email jobs ([#286](https://github.com/whisper-money/whisper-money/issues/286)) ([f408dbe](https://github.com/whisper-money/whisper-money/commit/f408dbe4c8a8ccbd7368ac675525a85b70c9abdf))
|
||||
* **settings:** centralize currency options and split profile/account support ([#256](https://github.com/whisper-money/whisper-money/issues/256)) ([3d58237](https://github.com/whisper-money/whisper-money/commit/3d5823728a18a146e9c420e7f924014bd66bd3c8))
|
||||
* store invested_amount in user currency instead of account currency ([#262](https://github.com/whisper-money/whisper-money/issues/262)) ([c3ff4c6](https://github.com/whisper-money/whisper-money/commit/c3ff4c684a50eb1b506d59954442f2ba7a41b04d))
|
||||
* **stripe:** add promo code generator ([#311](https://github.com/whisper-money/whisper-money/issues/311)) ([69665c3](https://github.com/whisper-money/whisper-money/commit/69665c3c588ad0b4d27594d2b55fdb185553483a))
|
||||
* **subscriptions:** add configurable trial period to paid plans ([#324](https://github.com/whisper-money/whisper-money/issues/324)) ([b399aaa](https://github.com/whisper-money/whisper-money/commit/b399aaaa0dcafc27f9f9665209f9aceecf0b70e7))
|
||||
* sync user leads to resend ([#283](https://github.com/whisper-money/whisper-money/issues/283)) ([dc0695c](https://github.com/whisper-money/whisper-money/commit/dc0695c2ca55d3447b814b44bd8f13848922f92a))
|
||||
* verify waitlist leads ([#285](https://github.com/whisper-money/whisper-money/issues/285)) ([d0aab3d](https://github.com/whisper-money/whisper-money/commit/d0aab3d11bad80fefb35fa01055347ce1413d18b))
|
||||
|
||||
## [0.1.19](https://github.com/whisper-money/whisper-money/compare/v0.1.18...v0.1.19) (2026-03-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **banking:** treat 429 rate limit as transient, skip error status on sync ([#224](https://github.com/whisper-money/whisper-money/issues/224)) ([5b9ae2a](https://github.com/whisper-money/whisper-money/commit/5b9ae2a5259ecf1e55e4074295c52dcc0429ef71))
|
||||
* **cashflow:** only count sign-matching transactions in Sankey category breakdown ([#232](https://github.com/whisper-money/whisper-money/issues/232)) ([9e2a9ca](https://github.com/whisper-money/whisper-money/commit/9e2a9cadfe0210e0f2a45da8dbcaab1552dc0844))
|
||||
* **ci:** allow deploy retry loop to survive curl timeout ([#233](https://github.com/whisper-money/whisper-money/issues/233)) ([cd40bc7](https://github.com/whisper-money/whisper-money/commit/cd40bc75d9b60acede4fc519f3f8f66ad8f560c3))
|
||||
* **haptics:** use a local WebHaptics wrapper ([#225](https://github.com/whisper-money/whisper-money/issues/225)) ([f600524](https://github.com/whisper-money/whisper-money/commit/f600524c2b834b9322fda1ca7a6881b43c5d5194))
|
||||
* prevent account label combobox crash ([#230](https://github.com/whisper-money/whisper-money/issues/230)) ([a60fd6f](https://github.com/whisper-money/whisper-money/commit/a60fd6f452b58d8ba9e4033dffc27a4f0c0fff15))
|
||||
* **settings:** restore budgets settings redirect ([#228](https://github.com/whisper-money/whisper-money/issues/228)) ([e5fcaee](https://github.com/whisper-money/whisper-money/commit/e5fcaee8f8a0c9badf0450fb209ff7cd7e4c0d2e))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **cashflow:** make income/expense category rows clickable to transactions ([#234](https://github.com/whisper-money/whisper-money/issues/234)) ([ec24565](https://github.com/whisper-money/whisper-money/commit/ec245655b8f5541a6bafec92edede97bf75573aa))
|
||||
|
||||
## [0.1.18](https://github.com/whisper-money/whisper-money/compare/v0.1.17...v0.1.18) (2026-03-12)
|
||||
|
||||
|
||||
|
|
|
|||
89
CLAUDE.md
89
CLAUDE.md
|
|
@ -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 <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions React with Inertia, React pages, React forms, or React navigation.
|
||||
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||
- `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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
whisper.money.local {
|
||||
# Use mkcert certificates (generated by setup script)
|
||||
# These certificates are automatically trusted by browsers
|
||||
tls /etc/caddy/certs/whisper.money.local.pem /etc/caddy/certs/whisper.money.local-key.pem
|
||||
|
||||
reverse_proxy host.docker.internal:8000
|
||||
|
||||
header {
|
||||
-Server
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
}
|
||||
|
||||
encode zstd gzip
|
||||
}
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
# Use the official PHP 8.4 FPM image
|
||||
FROM php:8.4-fpm
|
||||
|
||||
ARG SENTRY_RELEASE
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
|
|
@ -32,12 +30,9 @@ COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
|||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
# Install Bun in a non-root path so supervisor workers running as www-data can execute it
|
||||
ENV BUN_INSTALL="/usr/local/bun"
|
||||
RUN curl -fsSL https://bun.com/install | bash \
|
||||
&& ln -sf /usr/local/bun/bin/bun /usr/local/bin/bun \
|
||||
&& chmod -R a+rx /usr/local/bun
|
||||
ENV PATH="/usr/local/bun/bin:${PATH}"
|
||||
# Install Bun
|
||||
RUN curl -fsSL https://bun.com/install | bash
|
||||
ENV PATH="/root/.bun/bin:${PATH}"
|
||||
|
||||
# Install memcached and redis servers
|
||||
RUN apt-get update && apt-get install -y memcached redis-server && rm -rf /var/lib/apt/lists/*
|
||||
|
|
@ -45,23 +40,18 @@ RUN apt-get update && apt-get install -y memcached redis-server && rm -rf /var/l
|
|||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
ENV SENTRY_RELEASE=${SENTRY_RELEASE}
|
||||
|
||||
# Copy dependency manifests first so Docker can reuse install layers across code changes
|
||||
COPY composer.json composer.lock ./
|
||||
RUN composer install --no-dev --no-scripts --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
COPY package.json bun.lock ./
|
||||
COPY scripts ./scripts
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy application files
|
||||
COPY . /app/.
|
||||
RUN composer dump-autoload --no-dev --optimize
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /var/log/nginx && mkdir -p /var/cache/nginx
|
||||
|
||||
# Install PHP dependencies
|
||||
RUN composer install --no-dev --optimize-autoloader
|
||||
|
||||
# Install Node.js dependencies
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Build assets with HIDE_AUTH_BUTTONS=false so Wayfinder generates all routes
|
||||
# (HIDE_AUTH_BUTTONS will be set to true at runtime via .env)
|
||||
RUN HIDE_AUTH_BUTTONS=false bun run build:ssr
|
||||
|
|
|
|||
|
|
@ -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. -->
|
||||
11
README.md
11
README.md
|
|
@ -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
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ The easiest way to get started is using our automated setup script:
|
|||
bash <(curl -fsSL https://whisper.money/setup.sh)
|
||||
```
|
||||
|
||||
After installation, just visit **<https://whisper.money.localhost>** in your browser.
|
||||
After installation, just visit **<https://whisper.money.local>** in your browser.
|
||||
|
||||
### Manual Setup
|
||||
|
||||
|
|
@ -100,12 +100,12 @@ composer run dev
|
|||
|
||||
This will concurrently start:
|
||||
|
||||
- PHP development server (via [Portless](https://portless.sh) HTTPS proxy)
|
||||
- PHP development server
|
||||
- Queue worker
|
||||
- Log viewer (Pail)
|
||||
- Vite dev server
|
||||
|
||||
The application will be available at **<https://dev.whisper.money.localhost>**. In git worktrees, the branch name is automatically prepended (e.g. `https://fix-ui.dev.whisper.money.localhost`).
|
||||
The application will be available at **<https://whisper.money.local>** or **<http://localhost:8000>** (direct PHP server).
|
||||
|
||||
## Running with Docker (Production Image)
|
||||
|
||||
|
|
@ -164,7 +164,6 @@ The template includes:
|
|||
| ----------------------- | ------- | -------------------------------------------------- |
|
||||
| `DRIP_EMAILS_ENABLED` | `true` | Enable drip emails (welcome, onboarding, feedback) |
|
||||
| `HIDE_AUTH_BUTTONS` | `false` | Hide login/register buttons on landing page |
|
||||
| `REGISTRATION_ENABLED` | `true` | Set to `false` to close public sign-ups (the `/register` routes are disabled and every registration CTA is hidden) while keeping `/login` open |
|
||||
| `SUBSCRIPTIONS_ENABLED` | `false` | Enable Stripe subscriptions |
|
||||
| `STRIPE_KEY` | - | Stripe publishable key |
|
||||
| `STRIPE_SECRET` | - | Stripe secret key |
|
||||
|
|
@ -172,7 +171,7 @@ The template includes:
|
|||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/?type=date&legend=top-left&repos=whisper-money%2Fwhisper-money)
|
||||
[](https://www.star-history.com/#whisper-money/whisper-money&type=date&legend=top-left)
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,67 +2,22 @@
|
|||
|
||||
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()
|
||||
->whereIn('name', array_column($defaultCategories, 'name'))
|
||||
->pluck('id', 'name');
|
||||
|
||||
$now = now();
|
||||
$pending = collect($defaultCategories)
|
||||
->reject(fn (array $category): bool => $existingCategories->has($category['name']))
|
||||
->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,
|
||||
]);
|
||||
|
||||
if ($pending->isEmpty()) {
|
||||
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()
|
||||
foreach ($defaultCategories as $category) {
|
||||
$user->categories()->firstOrCreate(
|
||||
['name' => $category['name']],
|
||||
$category
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -70,7 +25,7 @@ class CreateDefaultCategories
|
|||
/**
|
||||
* 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}>
|
||||
*/
|
||||
public static function getDefaultCategories(string $locale = 'en'): array
|
||||
{
|
||||
|
|
@ -82,10 +37,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 +45,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}>
|
||||
*/
|
||||
private static function getBaseCategories(): array
|
||||
{
|
||||
|
|
@ -110,35 +60,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 +96,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 +144,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 +180,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 +222,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 +246,6 @@ class CreateDefaultCategories
|
|||
],
|
||||
[
|
||||
'name' => 'Online services',
|
||||
'parent' => 'Online transactions',
|
||||
'icon' => 'Server',
|
||||
'color' => 'fuchsia',
|
||||
'type' => 'expense',
|
||||
|
|
@ -336,23 +260,19 @@ class CreateDefaultCategories
|
|||
'name' => 'Investments',
|
||||
'icon' => 'LineChart',
|
||||
'color' => 'lime',
|
||||
'type' => CategoryType::Investment->value,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Savings',
|
||||
'icon' => 'PiggyBank',
|
||||
'color' => 'lime',
|
||||
'type' => CategoryType::Savings->value,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Other investments',
|
||||
'parent' => 'Investments',
|
||||
'icon' => 'TrendingUp',
|
||||
'color' => 'lime',
|
||||
'type' => CategoryType::Investment->value,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Financial services and commission',
|
||||
|
|
@ -392,7 +312,6 @@ class CreateDefaultCategories
|
|||
],
|
||||
[
|
||||
'name' => 'Lottery',
|
||||
'parent' => 'Gambling',
|
||||
'icon' => 'TicketPercent',
|
||||
'color' => 'purple',
|
||||
'type' => 'expense',
|
||||
|
|
@ -417,7 +336,6 @@ class CreateDefaultCategories
|
|||
],
|
||||
[
|
||||
'name' => 'Other personal transfers',
|
||||
'parent' => 'Personal transfers',
|
||||
'icon' => 'ArrowLeftRight',
|
||||
'color' => 'cyan',
|
||||
'type' => 'transfer',
|
||||
|
|
@ -493,7 +411,6 @@ class CreateDefaultCategories
|
|||
'icon' => 'Users',
|
||||
'color' => 'blue',
|
||||
'type' => 'transfer',
|
||||
'cashflow_direction' => CategoryCashflowDirection::Inflow->value,
|
||||
],
|
||||
[
|
||||
'name' => 'Returned payments',
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Enums\Locale;
|
||||
use App\Models\User;
|
||||
use App\Services\LandingAuthOverrideService;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
|
@ -13,8 +11,6 @@ class CreateNewUser implements CreatesNewUsers
|
|||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
public function __construct(private LandingAuthOverrideService $landingAuthOverrideService) {}
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
|
|
@ -22,10 +18,6 @@ class CreateNewUser implements CreatesNewUsers
|
|||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
if ($this->landingAuthOverrideService->authButtonsHidden(request())) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
|
|
@ -36,15 +28,13 @@ class CreateNewUser implements CreatesNewUsers
|
|||
Rule::unique(User::class),
|
||||
],
|
||||
'password' => $this->passwordRules(),
|
||||
'timezone' => ['nullable', 'string', 'max:255'],
|
||||
])->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(),
|
||||
]);
|
||||
|
||||
if (! config('mail.email_verification_enabled')) {
|
||||
|
|
@ -55,19 +45,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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
|
|
@ -10,7 +9,7 @@ trait PasswordValidationRules
|
|||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, Rule|array<mixed>|string>
|
||||
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\OpenBanking;
|
||||
|
||||
use App\Contracts\BankingProviderInterface;
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Models\BankingConnection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DisconnectBankingConnection
|
||||
{
|
||||
public function __construct(private BankingProviderInterface $provider) {}
|
||||
|
||||
public function handle(BankingConnection $connection, bool $deleteAccounts = false): void
|
||||
{
|
||||
if ($connection->isEnableBanking() && $connection->session_id && $connection->isActive()) {
|
||||
try {
|
||||
$this->provider->revokeSession($connection->session_id);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to revoke EnableBanking session', [
|
||||
'connection_id' => $connection->id,
|
||||
'session_id' => $connection->session_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteAccounts) {
|
||||
$connection->accounts->each(function ($account): void {
|
||||
$account->transactions()->delete();
|
||||
$account->balances()->delete();
|
||||
$account->delete();
|
||||
});
|
||||
} else {
|
||||
$connection->accounts()->update([
|
||||
'banking_connection_id' => null,
|
||||
'external_account_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$connection->update(['status' => BankingConnectionStatus::Revoked]);
|
||||
$connection->delete();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.0–1.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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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.0–1.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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\RealEstateDetail;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ApplyRealEstateRevaluationCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'real-estate:apply-revaluation';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Apply monthly revaluation to real estate accounts based on their annual revaluation percentage';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$details = RealEstateDetail::query()
|
||||
->whereNotNull('revaluation_percentage')
|
||||
->where('revaluation_percentage', '!=', 0)
|
||||
->with('account')
|
||||
->get();
|
||||
|
||||
$applied = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($details as $detail) {
|
||||
$account = $detail->account;
|
||||
|
||||
if (! $account) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$latestBalance = $account->balances()
|
||||
->orderByDesc('balance_date')
|
||||
->first();
|
||||
|
||||
if (! $latestBalance) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$annualRate = (float) $detail->revaluation_percentage / 100;
|
||||
$monthlyMultiplier = (1 + $annualRate) ** (1 / 12);
|
||||
$newBalance = (int) round($latestBalance->balance * $monthlyMultiplier);
|
||||
|
||||
$account->balances()->updateOrCreate(
|
||||
['balance_date' => now()->toDateString()],
|
||||
['balance' => $newBalance],
|
||||
);
|
||||
|
||||
$applied++;
|
||||
}
|
||||
|
||||
$this->info("Revaluation applied to {$applied} account(s), skipped {$skipped}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
<?php
|
||||
|
||||
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;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class CancelFreeEnableBankingConnectionsCommand extends Command
|
||||
{
|
||||
protected $signature = 'banking:cancel-free-enablebanking';
|
||||
|
||||
protected $description = 'Close Enable Banking connections for free users at month end';
|
||||
|
||||
public function handle(DisconnectBankingConnection $disconnectBankingConnection): int
|
||||
{
|
||||
$cutoff = now()->subHours(6);
|
||||
|
||||
$connections = BankingConnection::query()
|
||||
->with(['user', 'accounts'])
|
||||
->whereHas('user')
|
||||
->where('provider', BankingProvider::EnableBanking)
|
||||
->where('status', '!=', BankingConnectionStatus::Revoked)
|
||||
->where('created_at', '<=', $cutoff)
|
||||
->get();
|
||||
|
||||
$count = $connections->count();
|
||||
|
||||
if ($count === 0) {
|
||||
$this->info('No eligible Enable Banking connections found for free users.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$revoked = 0;
|
||||
$skipped = 0;
|
||||
|
||||
$connections
|
||||
->groupBy('user_id')
|
||||
->each(function ($userConnections) use ($disconnectBankingConnection, &$revoked, &$skipped): void {
|
||||
$user = $userConnections->first()?->user;
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->hasProPlan()) {
|
||||
$skipped += $userConnections->count();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($userConnections as $connection) {
|
||||
$disconnectBankingConnection->handle($connection);
|
||||
$revoked++;
|
||||
}
|
||||
|
||||
if ($user->canReceiveEmails()) {
|
||||
Mail::to($user)->send(new EnableBankingConnectionsCancelledEmail(
|
||||
$user,
|
||||
$userConnections->count(),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Revoked {$revoked} Enable Banking connection(s). Skipped paid users: {$skipped}.");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,6 @@ trait ResolvesFeatures
|
|||
|
||||
private function getStringBasedFeatures(): array
|
||||
{
|
||||
return [];
|
||||
return ['open-banking', 'account-mapping'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,9 @@
|
|||
|
||||
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;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteUserCommand extends Command
|
||||
{
|
||||
|
|
@ -22,16 +20,16 @@ class DeleteUserCommand extends Command
|
|||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Mark a user as deleted while preserving their data';
|
||||
protected $description = 'Delete a user and all their associated data';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(DisconnectBankingConnection $disconnectBankingConnection): int
|
||||
public function handle(): int
|
||||
{
|
||||
$email = $this->argument('email');
|
||||
|
||||
$user = User::withTrashed()->where('email', $email)->first();
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if (! $user) {
|
||||
$this->error("User with email '{$email}' not found.");
|
||||
|
|
@ -39,75 +37,48 @@ class DeleteUserCommand extends Command
|
|||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($user->trashed()) {
|
||||
$this->info("User '{$email}' is already marked as deleted.");
|
||||
// Check for active subscriptions
|
||||
if ($user->subscribed('default')) {
|
||||
$this->error('Cannot delete user with an active subscription. Please cancel the subscription first.');
|
||||
|
||||
return self::SUCCESS;
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $this->confirm("Are you sure you want to mark user '{$user->name}' ({$user->email}) as deleted? Their data will be preserved.")) {
|
||||
if (! $this->confirm("Are you sure you want to delete user '{$user->name}' ({$user->email}) and all their data?")) {
|
||||
$this->info('Deletion cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$subscription = $this->activeSubscription($user);
|
||||
$enableBankingConnections = $user->bankingConnections()
|
||||
->with('accounts')
|
||||
->where('provider', BankingProvider::EnableBanking)
|
||||
->get();
|
||||
DB::transaction(function () use ($user) {
|
||||
// Delete account balances through accounts
|
||||
foreach ($user->accounts as $account) {
|
||||
$account->balances()->delete();
|
||||
}
|
||||
|
||||
if ($subscription && ! $this->confirm("User '{$user->email}' has an active Stripe subscription. Cancel it before deleting the user?")) {
|
||||
$this->info('Deletion cancelled.');
|
||||
// Delete all related data
|
||||
$user->encryptedMessage()->delete();
|
||||
$user->transactions()->delete();
|
||||
$user->accounts()->delete();
|
||||
$user->categories()->delete();
|
||||
$user->automationRules()->delete();
|
||||
$user->labels()->delete();
|
||||
$user->mailLogs()->delete();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
// Delete user's banks
|
||||
DB::table('banks')->where('user_id', $user->id)->delete();
|
||||
|
||||
if ($enableBankingConnections->isNotEmpty() && ! $this->confirm("User '{$user->email}' has {$enableBankingConnections->count()} Enable Banking connection(s). Revoke them and keep linked accounts as manual accounts?")) {
|
||||
$this->info('Deletion cancelled.');
|
||||
// Delete Cashier subscription data if exists
|
||||
if ($user->subscriptions()->exists()) {
|
||||
$user->subscriptions()->delete();
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
// Delete the user
|
||||
$user->delete();
|
||||
});
|
||||
|
||||
if ($subscription) {
|
||||
$this->cancelSubscription($user, $subscription);
|
||||
$this->info("Cancelled active Stripe subscription for '{$user->email}'.");
|
||||
}
|
||||
|
||||
foreach ($enableBankingConnections as $connection) {
|
||||
$disconnectBankingConnection->handle($connection, deleteAccounts: false);
|
||||
}
|
||||
|
||||
if ($enableBankingConnections->isNotEmpty()) {
|
||||
$this->info("Revoked {$enableBankingConnections->count()} Enable Banking connection(s) for '{$user->email}'.");
|
||||
}
|
||||
|
||||
$user->markAsDeleted();
|
||||
|
||||
$this->info("User '{$email}' has been marked as deleted. Their data remains in the database.");
|
||||
$this->info("User '{$email}' and all associated data have been deleted successfully.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function activeSubscription(User $user): ?Subscription
|
||||
{
|
||||
$subscription = $user->subscription('default');
|
||||
|
||||
if (! $subscription || ! $subscription->valid()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $subscription;
|
||||
}
|
||||
|
||||
private function cancelSubscription(User $user, Subscription $subscription): void
|
||||
{
|
||||
if ($user->hasStripeId()) {
|
||||
$subscription->cancelNow();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$subscription->markAsCanceled();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Stripe\Coupon;
|
||||
use Stripe\Exception\ApiErrorException;
|
||||
use Stripe\Exception\InvalidRequestException;
|
||||
|
||||
class EnsureLaunchCouponsCommand extends Command
|
||||
{
|
||||
public const COUPON_FOUNDER_FOREVER = 'wm_founder_forever';
|
||||
|
||||
public const COUPON_EARLYBIRD_MONTHLY = 'wm_earlybird_monthly';
|
||||
|
||||
public const COUPON_EARLYBIRD_YEARLY = 'wm_earlybird_yearly';
|
||||
|
||||
protected $signature = 'stripe:ensure-launch-coupons {--dry-run : Show what would happen without writing to Stripe}';
|
||||
|
||||
protected $description = 'Idempotently create the launch invitation coupons in Stripe';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$stripe = Cashier::stripe();
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$monthlyPriceId = $this->resolvePriceId(config('subscriptions.plans.monthly.stripe_lookup_key'));
|
||||
$yearlyPriceId = $this->resolvePriceId(config('subscriptions.plans.yearly.stripe_lookup_key'));
|
||||
|
||||
if ($monthlyPriceId === null || $yearlyPriceId === null) {
|
||||
$this->error('Unable to resolve monthly/yearly Stripe price IDs. Run `php artisan stripe:sync-prices` first.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$monthlyProductId = $stripe->prices->retrieve($monthlyPriceId)->product;
|
||||
$yearlyProductId = $stripe->prices->retrieve($yearlyPriceId)->product;
|
||||
|
||||
$coupons = [
|
||||
[
|
||||
'id' => self::COUPON_FOUNDER_FOREVER,
|
||||
'name' => 'WM Founder – 100% off forever',
|
||||
'percent_off' => 100,
|
||||
'duration' => 'forever',
|
||||
'applies_to' => null,
|
||||
],
|
||||
[
|
||||
'id' => self::COUPON_EARLYBIRD_MONTHLY,
|
||||
'name' => 'WM Early Bird – 2 months free',
|
||||
'percent_off' => 100,
|
||||
'duration' => 'repeating',
|
||||
'duration_in_months' => 2,
|
||||
'applies_to' => ['products' => [$monthlyProductId]],
|
||||
],
|
||||
[
|
||||
'id' => self::COUPON_EARLYBIRD_YEARLY,
|
||||
'name' => 'WM Early Bird – 25% off year 1',
|
||||
'percent_off' => 25,
|
||||
'duration' => 'once',
|
||||
'applies_to' => ['products' => [$yearlyProductId]],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($coupons as $config) {
|
||||
$existing = $this->findCoupon($config['id']);
|
||||
|
||||
if ($existing !== null) {
|
||||
$this->info("✔ Coupon `{$config['id']}` already exists.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line("[dry-run] Would create coupon `{$config['id']}`.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = array_filter([
|
||||
'id' => $config['id'],
|
||||
'name' => $config['name'],
|
||||
'percent_off' => $config['percent_off'],
|
||||
'duration' => $config['duration'],
|
||||
'duration_in_months' => $config['duration_in_months'] ?? null,
|
||||
'applies_to' => $config['applies_to'] ?? null,
|
||||
], fn ($value): bool => $value !== null);
|
||||
|
||||
try {
|
||||
$stripe->coupons->create($payload);
|
||||
} catch (ApiErrorException $exception) {
|
||||
$this->error("Failed creating `{$config['id']}`: {$exception->getMessage()}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("+ Created coupon `{$config['id']}`.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function findCoupon(string $id): ?Coupon
|
||||
{
|
||||
try {
|
||||
return Cashier::stripe()->coupons->retrieve($id);
|
||||
} catch (InvalidRequestException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolvePriceId(?string $lookupKey): ?string
|
||||
{
|
||||
if ($lookupKey === null || $lookupKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prices = Cashier::stripe()->prices->all([
|
||||
'lookup_keys' => [$lookupKey],
|
||||
'limit' => 1,
|
||||
'expand' => ['data.product'],
|
||||
]);
|
||||
|
||||
return $prices->data[0]->id ?? null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\LandingAuthOverrideService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class GenerateLandingAuthLinkCommand extends Command
|
||||
{
|
||||
public function __construct(private LandingAuthOverrideService $landingAuthOverrideService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected $signature = 'landing:auth-link
|
||||
{--days=7 : Number of days before the link expires}';
|
||||
|
||||
protected $description = 'Generate a signed landing page link that unlocks authentication';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = filter_var($this->option('days'), FILTER_VALIDATE_INT);
|
||||
|
||||
if ($days === false || $days < 1) {
|
||||
$this->error('Days must be a positive integer.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$url = $this->landingAuthOverrideService->generateSignedUrl($days);
|
||||
|
||||
$this->line($url);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\AccountType;
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountBalance;
|
||||
use App\Services\LoanAmortizationService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class GenerateMonthlyLoanBalances extends Command
|
||||
{
|
||||
protected $signature = 'loans:generate-balances';
|
||||
|
||||
protected $description = 'Generate monthly balance entries for loan accounts with amortization details';
|
||||
|
||||
public function __construct(protected LoanAmortizationService $amortizationService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Generating monthly loan balances...');
|
||||
|
||||
$accounts = Account::query()
|
||||
->where('type', AccountType::Loan)
|
||||
->whereHas('loanDetail')
|
||||
->with('loanDetail')
|
||||
->get();
|
||||
|
||||
$generatedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
$balanceDate = now()->startOfMonth()->toDateString();
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
$loanDetail = $account->loanDetail;
|
||||
|
||||
$existingBalance = AccountBalance::query()
|
||||
->where('account_id', $account->id)
|
||||
->where('balance_date', $balanceDate)
|
||||
->exists();
|
||||
|
||||
if ($existingBalance) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$projectedBalance = $this->amortizationService->getBalanceAtDate(
|
||||
$loanDetail,
|
||||
now(),
|
||||
);
|
||||
|
||||
AccountBalance::create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $balanceDate,
|
||||
'balance' => $projectedBalance,
|
||||
]);
|
||||
|
||||
$generatedCount++;
|
||||
$this->info("Generated balance for: {$account->name} ({$projectedBalance} cents)");
|
||||
}
|
||||
|
||||
$this->info("Generated {$generatedCount} balance entries, skipped {$skippedCount} (already exist)");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use RuntimeException;
|
||||
use Stripe\Exception\ApiErrorException;
|
||||
use Stripe\PromotionCode;
|
||||
|
||||
class GenerateStripePromotionCodesCommand extends Command
|
||||
{
|
||||
private const DEFAULT_COUPON_ID = '0E5fAsXG';
|
||||
|
||||
protected $signature = 'stripe:generate-promotion-codes
|
||||
{count : Number of promotion codes to generate}
|
||||
{--coupon='.self::DEFAULT_COUPON_ID.' : Stripe coupon ID to attach to each code}';
|
||||
|
||||
protected $description = 'Generate single-use Stripe promotion codes';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$count = filter_var($this->argument('count'), FILTER_VALIDATE_INT);
|
||||
|
||||
if ($count === false || $count < 1) {
|
||||
$this->error('Count must be a positive integer.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$couponId = (string) $this->option('coupon');
|
||||
$generatedCodes = [];
|
||||
$rows = [];
|
||||
|
||||
$this->info("Generating {$count} single-use promotion codes for coupon {$couponId}...");
|
||||
|
||||
for ($index = 1; $index <= $count; $index++) {
|
||||
try {
|
||||
$promotionCode = $this->createPromotionCode($couponId, $generatedCodes);
|
||||
} catch (ApiErrorException $exception) {
|
||||
$this->error("Failed generating promotion code {$index}: {$exception->getMessage()}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$generatedCodes[] = $promotionCode->code;
|
||||
$rows[] = [$index, $promotionCode->id, $promotionCode->code];
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->table(['#', 'Stripe ID', 'Code'], $rows);
|
||||
$this->newLine();
|
||||
$this->info('Generated '.$count.' promotion code'.($count === 1 ? '' : 's').'.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $generatedCodes
|
||||
*/
|
||||
private function createPromotionCode(string $couponId, array $generatedCodes): PromotionCode
|
||||
{
|
||||
for ($attempt = 1; $attempt <= 5; $attempt++) {
|
||||
$code = $this->makeCode($generatedCodes);
|
||||
|
||||
try {
|
||||
return Cashier::stripe()->promotionCodes->create([
|
||||
'coupon' => $couponId,
|
||||
'code' => $code,
|
||||
'max_redemptions' => 1,
|
||||
]);
|
||||
} catch (ApiErrorException $exception) {
|
||||
if ($attempt < 5 && $this->isDuplicateCodeError($exception)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Unable to generate a unique promotion code after 5 attempts.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $generatedCodes
|
||||
*/
|
||||
private function makeCode(array $generatedCodes): string
|
||||
{
|
||||
do {
|
||||
$code = 'WM-'.Str::upper(Str::random(10));
|
||||
} while (in_array($code, $generatedCodes, true));
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
private function isDuplicateCodeError(ApiErrorException $exception): bool
|
||||
{
|
||||
$message = Str::lower($exception->getMessage());
|
||||
|
||||
return str_contains($message, 'code') && str_contains($message, 'exist');
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\UserLead;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
#[Signature('leads:resend-verification-emails {--dry-run : Show what would happen without dispatching emails}')]
|
||||
#[Description('Resend verification emails to leads that have not yet verified their email address')]
|
||||
class ResendLeadVerificationEmailsCommand extends Command
|
||||
{
|
||||
public function handle(): int
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn('DRY RUN — no emails will be dispatched.');
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$leads = UserLead::query()->whereNull('email_verified_at')->get();
|
||||
|
||||
if ($leads->isEmpty()) {
|
||||
$this->info('No unverified leads found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$dispatched = 0;
|
||||
|
||||
$progressBar = $this->output->createProgressBar($leads->count());
|
||||
$progressBar->start();
|
||||
|
||||
foreach ($leads as $lead) {
|
||||
if (! $isDryRun) {
|
||||
$lead->sendEmailVerificationNotification();
|
||||
}
|
||||
|
||||
$dispatched++;
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$this->table(
|
||||
['Action', 'Count'],
|
||||
[
|
||||
['dispatched'.($isDryRun ? ' (dry run)' : ''), $dispatched],
|
||||
]
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\UserLead;
|
||||
use App\Services\ResendService;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
#[Signature('resend:sync-leads {--since=24 : Sync leads created within the last N hours. Use 0 to sync all.}')]
|
||||
#[Description('Sync user leads to the Resend leads segment (last 24h by default)')]
|
||||
class ResendSyncLeadsCommand extends Command
|
||||
{
|
||||
public function handle(ResendService $resendService): int
|
||||
{
|
||||
$since = (int) $this->option('since');
|
||||
|
||||
if (! config('services.resend.key')) {
|
||||
$this->error('Resend API key not configured.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! config('services.resend.leads_segment_id')) {
|
||||
$this->error('Resend leads segment ID not configured.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$leads = UserLead::query()
|
||||
->whereNotNull('email_verified_at')
|
||||
->when($since > 0, fn ($query) => $query->where('created_at', '>=', now()->subHours($since)))
|
||||
->get();
|
||||
|
||||
if ($leads->isEmpty()) {
|
||||
$this->info('No user leads to sync.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Syncing {$leads->count()} user leads to Resend...");
|
||||
|
||||
$progressBar = $this->output->createProgressBar($leads->count());
|
||||
$progressBar->start();
|
||||
|
||||
$failed = 0;
|
||||
|
||||
foreach ($leads as $lead) {
|
||||
try {
|
||||
$resendService->syncLead($lead);
|
||||
} catch (\Exception $exception) {
|
||||
$failed++;
|
||||
$this->newLine();
|
||||
$this->warn("Failed to sync {$lead->email}: {$exception->getMessage()}");
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$synced = $leads->count() - $failed;
|
||||
$this->info("Synced {$synced} user leads to Resend.");
|
||||
|
||||
if ($failed > 0) {
|
||||
$this->warn("Failed to sync {$failed} user leads.");
|
||||
}
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
@ -156,7 +148,7 @@ class ResetDemoAccountCommand extends Command
|
|||
{
|
||||
$bbvaBank = Bank::query()->whereNull('user_id')->where('name', 'BBVA')->first()
|
||||
?? Bank::factory()->create(['user_id' => null]);
|
||||
$ingBank = Bank::query()->whereNull('user_id')->where('name', 'ING Direct')->first()
|
||||
$ingBank = Bank::query()->whereNull('user_id')->where('name', 'ING')->first()
|
||||
?? Bank::factory()->create(['user_id' => null]);
|
||||
$indexaCapitalBank = Bank::query()->whereNull('user_id')->where('name', 'Indexa Capital')->first()
|
||||
?? Bank::factory()->create(['user_id' => null]);
|
||||
|
|
@ -476,6 +468,8 @@ class ResetDemoAccountCommand extends Command
|
|||
$currentDate->addDay();
|
||||
}
|
||||
break;
|
||||
case BudgetPeriodType::Custom:
|
||||
break;
|
||||
}
|
||||
|
||||
$iteration++;
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\UserLead;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
#[Signature('leads:retry-failed-jobs {--dry-run : Show what would happen without making changes}')]
|
||||
#[Description('Selectively retry failed lead email jobs, forgetting stale ones whose lead was deleted or is not yet verified')]
|
||||
class RetryLeadFailedJobsCommand extends Command
|
||||
{
|
||||
public function handle(): int
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn('DRY RUN — no changes will be made.');
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$failedJobs = DB::table('failed_jobs')
|
||||
->where('queue', 'emails')
|
||||
->get();
|
||||
|
||||
if ($failedJobs->isEmpty()) {
|
||||
$this->info('No failed email jobs found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$retried = 0;
|
||||
$forgotten = 0;
|
||||
$skipped = 0;
|
||||
|
||||
$progressBar = $this->output->createProgressBar($failedJobs->count());
|
||||
$progressBar->start();
|
||||
|
||||
foreach ($failedJobs as $job) {
|
||||
$payload = json_decode($job->payload, true);
|
||||
$displayName = $payload['displayName'] ?? '';
|
||||
$command = $payload['data']['command'] ?? '';
|
||||
|
||||
$leadId = $this->extractLeadId($command);
|
||||
|
||||
if ($leadId === null) {
|
||||
$skipped++;
|
||||
$progressBar->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$lead = UserLead::find($leadId);
|
||||
$action = $this->determineAction($lead, $displayName);
|
||||
|
||||
if ($action === 'retry') {
|
||||
$retried++;
|
||||
if (! $isDryRun) {
|
||||
$this->callSilently('queue:retry', ['id' => [$job->uuid]]);
|
||||
}
|
||||
} else {
|
||||
$forgotten++;
|
||||
if (! $isDryRun) {
|
||||
$this->callSilently('queue:forget', ['id' => [$job->uuid]]);
|
||||
}
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$this->table(
|
||||
['Action', 'Count'],
|
||||
[
|
||||
['retried'.($isDryRun ? ' (dry run)' : ''), $retried],
|
||||
['forgotten'.($isDryRun ? ' (dry run)' : ''), $forgotten],
|
||||
['skipped (no lead ID found)', $skipped],
|
||||
]
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the UserLead UUID from a serialized job payload command string.
|
||||
*
|
||||
* Handles both queued mailables (id stored as a single string) and
|
||||
* queued notifications (id stored as a single-element array).
|
||||
*/
|
||||
private function extractLeadId(string $command): ?string
|
||||
{
|
||||
// Mail jobs store the lead as a single string ID
|
||||
if (preg_match('/App\\\\Models\\\\UserLead";s:2:"id";s:\d+:"([0-9a-f-]{36})"/', $command, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// Notification jobs store notifiables as an array of IDs
|
||||
if (preg_match('/App\\\\Models\\\\UserLead";s:2:"id";a:\d+:\{i:0;s:\d+:"([0-9a-f-]{36})"/', $command, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to retry or forget a failed job based on lead state.
|
||||
*
|
||||
* Rules:
|
||||
* - Lead deleted → forget (DDoS cleanup or similar)
|
||||
* - Lead verified → retry (Resend rate limit was the issue)
|
||||
* - Lead unverified + VerifyUserLeadEmailNotification → retry (still needs the verification email)
|
||||
* - Lead unverified + anything else → forget (waitlist emails to unverified leads are meaningless)
|
||||
*/
|
||||
private function determineAction(?UserLead $lead, string $displayName): string
|
||||
{
|
||||
if ($lead === null) {
|
||||
return 'forget';
|
||||
}
|
||||
|
||||
if ($lead->hasVerifiedEmail()) {
|
||||
return 'retry';
|
||||
}
|
||||
|
||||
if (str_contains($displayName, 'VerifyUserLeadEmailNotification')) {
|
||||
return 'retry';
|
||||
}
|
||||
|
||||
return 'forget';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)).'%';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,191 +2,67 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\LeadCohort;
|
||||
use App\Mail\UserLeadInvitation;
|
||||
use App\Models\UserLead;
|
||||
use App\Services\LeadCohortResolver;
|
||||
use App\Services\LeadPromoCodeAllocator;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Throwable;
|
||||
|
||||
class SendUserLeadInvitations extends Command
|
||||
{
|
||||
protected $signature = 'leads:send-invitations
|
||||
{--limit=50 : Maximum number of leads to invite in this batch}
|
||||
{--email= : Invite a specific pending lead by email address}
|
||||
{--cohort= : Restrict to a single cohort (founder, founder_referrer, early_bird, waitlist)}
|
||||
{--dry-run : Show what would happen without sending or creating Stripe codes}
|
||||
{--force : Skip confirmation prompt}';
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'leads:send-invitations {--force : Skip confirmation prompt}';
|
||||
|
||||
protected $description = 'Send launch invitation emails to the next batch of waitlist leads';
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Send invitation emails to all UserLeads with FOUNDER promo code';
|
||||
|
||||
protected $aliases = [
|
||||
'leads:send-invite',
|
||||
'leads:send-invites',
|
||||
];
|
||||
|
||||
public function handle(LeadCohortResolver $resolver, LeadPromoCodeAllocator $allocator): int
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$limit = (int) $this->option('limit');
|
||||
if ($limit < 1) {
|
||||
$this->error('Limit must be a positive integer.');
|
||||
$leads = UserLead::query()->get();
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$emailFilter = $this->resolveEmailFilter();
|
||||
$cohortFilter = $this->resolveCohortFilter();
|
||||
|
||||
if ($emailFilter === false || $cohortFilter === false) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$candidates = UserLead::query()
|
||||
->whereNotNull('position')
|
||||
->where('position', '>', 0)
|
||||
->whereNull('invitation_sent_at')
|
||||
->when(
|
||||
$emailFilter !== null,
|
||||
fn ($query) => $query->where('email', $emailFilter),
|
||||
fn ($query) => $query->orderBy('position')->limit($limit * 5), // over-fetch when filtering by cohort
|
||||
)
|
||||
->get();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
if ($emailFilter !== null) {
|
||||
$this->error("No pending lead found for {$emailFilter}.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('No pending leads found.');
|
||||
if ($leads->isEmpty()) {
|
||||
$this->info('No user leads found in the database.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$plan = [];
|
||||
foreach ($candidates as $lead) {
|
||||
$cohort = $resolver->resolve($lead);
|
||||
if ($cohort === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($cohortFilter !== null && $cohort !== $cohortFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$plan[] = [$lead, $cohort];
|
||||
|
||||
if (count($plan) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($plan === []) {
|
||||
$this->info('No leads matched the requested filters.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['#', 'Position', 'Email', 'Cohort'],
|
||||
array_map(
|
||||
fn (array $row, int $i): array => [
|
||||
$i + 1,
|
||||
$row[0]->position,
|
||||
$row[0]->email,
|
||||
$row[1]->value,
|
||||
],
|
||||
$plan,
|
||||
array_keys($plan),
|
||||
),
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('[dry-run] No emails sent.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$this->info("Found {$leads->count()} user lead(s).");
|
||||
|
||||
if (! $this->option('force')) {
|
||||
if (! $this->confirm('Send these invitation emails?', true)) {
|
||||
if (! $this->confirm("Do you want to send invitation emails to {$leads->count()} lead(s)?", true)) {
|
||||
$this->info('Cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
$failed = 0;
|
||||
$progressBar = $this->output->createProgressBar(count($plan));
|
||||
$this->info('Sending invitation emails...');
|
||||
|
||||
$progressBar = $this->output->createProgressBar($leads->count());
|
||||
$progressBar->start();
|
||||
|
||||
foreach ($plan as [$lead, $cohort]) {
|
||||
try {
|
||||
/** @var UserLead $lead */
|
||||
/** @var LeadCohort $cohort */
|
||||
$lead->cohort = $cohort;
|
||||
$allocator->ensureCodes($lead, $cohort);
|
||||
|
||||
Mail::to($lead->email)->send(new UserLeadInvitation($lead->fresh(), $cohort));
|
||||
|
||||
$lead->forceFill(['invitation_sent_at' => now()])->save();
|
||||
$sent++;
|
||||
} catch (Throwable $exception) {
|
||||
$failed++;
|
||||
$this->error("Failed for {$lead->email}: {$exception->getMessage()}");
|
||||
report($exception);
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
foreach ($leads as $lead) {
|
||||
Mail::to($lead->email)->send(new UserLeadInvitation($lead));
|
||||
$sent++;
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine();
|
||||
|
||||
$this->info("Queued {$sent} invitation email(s)".($failed > 0 ? " ({$failed} failed)" : '').'.');
|
||||
$this->info("Successfully queued {$sent} invitation email(s)!");
|
||||
|
||||
return $failed === 0 ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private function resolveCohortFilter(): LeadCohort|false|null
|
||||
{
|
||||
$cohort = $this->option('cohort');
|
||||
|
||||
if ($cohort === null || $cohort === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resolved = LeadCohort::tryFrom((string) $cohort);
|
||||
|
||||
if ($resolved === null) {
|
||||
$this->error("Unknown cohort `{$cohort}`. Valid values: founder, founder_referrer, early_bird, waitlist.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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']})"
|
||||
: '—';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -42,29 +41,10 @@ class SyncBankingConnections extends Command
|
|||
}
|
||||
|
||||
$query = BankingConnection::query()
|
||||
->whereHas('user')
|
||||
->where('status', BankingConnectionStatus::Active)
|
||||
->where(function ($query) {
|
||||
$query->where(function ($query) {
|
||||
$query->where(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Active)
|
||||
->orWhere(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Error)
|
||||
->where('consecutive_sync_failures', '<', SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES);
|
||||
});
|
||||
})->where(function ($query) {
|
||||
$query->whereNull('valid_until')
|
||||
->orWhere('valid_until', '>', now());
|
||||
});
|
||||
})->orWhere(function ($query) {
|
||||
$query->where('provider', BankingProvider::EnableBanking)
|
||||
->where('status', BankingConnectionStatus::Active)
|
||||
->whereNotNull('valid_until')
|
||||
->where('valid_until', '<=', now());
|
||||
});
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereNull('rate_limited_until')
|
||||
->orWhere('rate_limited_until', '<=', now());
|
||||
$query->whereNull('valid_until')
|
||||
->orWhere('valid_until', '>', now());
|
||||
});
|
||||
|
||||
if ($connectionId) {
|
||||
|
|
@ -93,9 +73,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
@ -104,7 +102,7 @@ class SyncStripePricesCommand extends Command
|
|||
}
|
||||
|
||||
/**
|
||||
* @return Price|null
|
||||
* @return \Stripe\Price|null
|
||||
*/
|
||||
private function findPriceByLookupKey(string $lookupKey): mixed
|
||||
{
|
||||
|
|
@ -140,7 +138,7 @@ class SyncStripePricesCommand extends Command
|
|||
}
|
||||
|
||||
/**
|
||||
* @param Price $price
|
||||
* @param \Stripe\Price $price
|
||||
*/
|
||||
private function priceMatches(mixed $price, int $amountInCents, string $currency, ?string $billingPeriod): bool
|
||||
{
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue