Compare commits

...

37 Commits
main ... v0.1.0

Author SHA1 Message Date
Víctor Falcón 367abb9a69 feat: add version tracking with git tags and changelog
- Add version field to package.json (0.1.0)
- Install release-it for automated releases with conventional changelog
- Create .release-it.json configuration
- Create initial CHANGELOG.md
- Share version via Inertia (reads from package.json)
- Display version in user dropdown menu
2025-12-28 18:43:27 +01:00
Víctor Falcón 841cec6037 chore: update PWA maskable icons 2025-12-28 18:16:48 +01:00
Víctor Falcón d9647a543a fix(charts): mobile ui, and desktop tooltips 2025-12-27 16:49:02 +00:00
Víctor Falcón 88594b9281 fix(mobile): account chart 2025-12-27 16:24:47 +00:00
Víctor Falcón 5fff2afa08
feat: add multiple chart view modes for net worth evolution (#37)
## Summary

- Add four view modes for net worth and account balance charts:
- **Stacked Accounts**: existing stacked bar chart showing balances by
account
  - **Line**: net worth line chart with linear/log scale toggle
  - **Change**: bar chart showing MoM%, YoY%, Rolling 12M changes
- **Waterfall**: bridge chart showing Start -> Change -> End transition

- Add Vitest for frontend unit testing with 52 tests covering all
calculation functions
- Proper sign handling for liabilities (credit cards and loans subtract
from net worth)
- Log scale disabled with warning when net worth includes zero or
negative values
- Shared components and logic between dashboard and account detail
charts

## New Files

- `vitest.config.ts` - Vitest configuration
- `resources/js/lib/chart-calculations.ts` - Pure computation functions
- `resources/js/lib/chart-calculations.test.ts` - 52 unit tests
- `resources/js/hooks/use-chart-views.ts` - Shared chart view state hook
- `resources/js/components/charts/` - Reusable chart components

## Test plan

- [x] Dashboard net worth chart shows view toggle and all four views
work
- [ ] Account detail page chart shows view toggle and three views work
(no stacked)
- [x] Line chart scale toggle works (log disabled with warning when
applicable)
- [x] Change chart series toggle (MoM, YoY, 12M EUR, 12M %) works
- [x] Waterfall chart month selector works
- [x] All 52 unit tests pass (`bun run test`)
- [x] Build succeeds (`bun run build`)
- [x] Dark mode styling correct
2025-12-27 13:54:22 +00:00
Víctor Falcón c88318e951
fix(sync): make transaction creation idempotent (#38)
## Summary
- When a transaction ID is provided during sync, check if it already
exists before creating
- If it exists, return the existing transaction with 200 status instead
of failing with duplicate key error
- Prevents duplicate transactions when sync retries occur due to network
issues

## Test plan
- [x] Attempt to create a transaction with a specific ID
- [x] Attempt to create another transaction with the same ID
- [x] Verify the second request returns the existing transaction instead
of an error
2025-12-27 13:23:05 +00:00
Víctor Falcón e6cab5eef5 ui: add hasKeyboard prop to modals 2025-12-25 13:03:38 +00:00
Víctor Falcón 08a75ddcca ui(fix): add hasKeyboard prop to dialogs
This property move the modal to the top on mobile, so we left space
bellow for the keyboard. On desktop is vertically centered as it was
before.
2025-12-25 12:33:25 +00:00
Víctor Falcón 1db8aa4643 ui(fix): use amount display for chart current balance 2025-12-25 12:15:35 +00:00
Víctor Falcón e84929d746 ui(fix): responsive design on account show page 2025-12-25 12:11:43 +00:00
Víctor Falcón 6e2ea65757 ui(fix): disable svg focus outline 2025-12-25 11:45:30 +00:00
Víctor Falcón a454d7ec25 Disable recharts outline 2025-12-24 17:51:48 +00:00
Víctor Falcón 70d6afec53 ui(fix): transaction filters on small screens 2025-12-24 12:52:20 +00:00
Víctor Falcón 9ac46a37e0 Add retry mechanism to email mailables and update supervisor config 2025-12-18 15:38:04 +01:00
Víctor Falcón 3eaa46fa27 feat(queue): Implement queueable email jobs with rate limiting 2025-12-18 12:54:05 +01:00
Víctor Falcón fb5c09dcd8 fix: use markdown to send user lead invitation mail 2025-12-18 12:12:31 +01:00
Víctor Falcón eb6d3ce091 welcome-page: explain e2e encryption 2025-12-18 09:33:34 +01:00
Víctor Falcón 7abc86218c command: send email to leads 2025-12-18 09:14:04 +01:00
Víctor Falcón c99812ae70 welcome-page: remove lead form 2025-12-18 08:49:15 +01:00
Víctor Falcón 10821e6679 Reapply "swap horizon -> queue:work on mysql"
This reverts commit 43d615869c.
2025-12-17 18:00:32 +01:00
Víctor Falcón 4a9f74d1b1 add best value to yearly price 2025-12-17 17:36:56 +01:00
Víctor Falcón 8f1f8dd405 fix: rong schedule import 2025-12-17 17:09:01 +01:00
Víctor Falcón f94110c83a pint 2025-12-17 17:08:02 +01:00
Víctor Falcón 9f5dedc8b9 horizon: notify admin is env var is set 2025-12-17 17:02:21 +01:00
Víctor Falcón d5d3c1ef1b horizon: takes snapshot every 5 min 2025-12-17 17:02:05 +01:00
Víctor Falcón 43d615869c Revert "swap horizon -> queue:work on mysql"
This reverts commit 214ad684a8.
2025-12-17 16:31:48 +01:00
Víctor Falcón 214ad684a8 swap horizon -> queue:work on mysql 2025-12-17 16:24:38 +01:00
Víctor Falcón 67347c36ed mail: whisper money logo on header 2025-12-17 15:29:55 +01:00
Víctor Falcón 35d91a3b13 chore(commands): unify command signatures 2025-12-17 12:36:57 +01:00
Víctor Falcón bbbe8e31bf command: delete user 2025-12-17 12:33:18 +01:00
Víctor Falcón 08f5d1f947 Test mail drip command 2025-12-17 12:23:32 +01:00
Víctor Falcón 2f6ee33d4d mails: header email 2025-12-17 12:08:36 +01:00
Víctor Falcón dbe9b8956f production: remove redis config 2025-12-17 11:53:28 +01:00
Víctor Falcón f36ddb4f36 Event listeners are autowired 2025-12-17 09:46:44 +01:00
Víctor Falcón 9cb063e515 Horizon: remove basic auth, add allowed emails list to env 2025-12-17 09:10:15 +01:00
Víctor Falcón 73bb25ae8f
feat: Implement drip email campaign system (#35)
## Summary

- Implement event-driven drip email campaign system for new user signups
- Add 5 targeted emails based on user journey (welcome, onboarding
reminder, promo code, import help, feedback)
- Configure Laravel Horizon with Redis queue for reliable job processing
- Track sent emails in `user_mail_logs` table to prevent duplicates

## Email Schedule

| Email | Timing | Condition |
|-------|--------|-----------|
| Welcome | 30 min after signup | All users |
| Onboarding Reminder | 1 day after signup | Not onboarded |
| Promo Code (FOUNDER) | 1 day after signup | Onboarded + has
transactions + not subscribed |
| Import Help | 1 day after signup | Onboarded + no transactions + not
subscribed |
| Feedback | 5 days after signup | Not subscribed |

## Architecture

```
User Registers → ScheduleDripEmailsListener
    ├─> SendWelcomeEmailJob (30 min delay)
    ├─> SendOnboardingReminderEmailJob (1 day delay)
    ├─> SendPromoCodeEmailJob (1 day delay)
    ├─> SendImportHelpEmailJob (1 day delay)
    └─> SendFeedbackEmailJob (5 days delay)
```

Each job checks conditions at execution time and either sends the email
+ logs it, or silently completes.

## Test plan

- [x] All 23 drip email tests pass
- [x] Migration creates `user_mail_logs` table
- [x] Jobs dispatch with correct delays
- [x] Emails only sent when conditions are met
- [x] Duplicate emails prevented via UserMailLog check
- [ ] Verify Horizon processes jobs in production
2025-12-17 08:34:17 +01:00
Víctor Falcón 54833fcdc0
feat: Configure Resend email integration (#34)
- Install symfony/resend-mailer package for Resend support
- Add RESEND_API_KEY to .env.example with documentation
- Create mail:test command to verify email configuration
- Users can now set MAIL_MAILER=resend in .env to use Resend
- Test command usage: php artisan mail:test email@example.com

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-16 14:57:19 +01:00
105 changed files with 4503 additions and 174 deletions

View File

@ -1,12 +0,0 @@
{
"permissions": {
"allow": [
"Bash(bun run format)",
"Bash(bun run lint)"
]
},
"enabledMcpjsonServers": [
"laravel-boost"
],
"enableAllProjectMcpServers": true
}

View File

@ -47,14 +47,15 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
MAIL_FROM_ADDRESS="hi@whisper.money"
MAIL_FROM_NAME="Whisper Money"
# Resend Email Service (set MAIL_MAILER=resend to use in production)
RESEND_API_KEY=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=

View File

@ -146,6 +146,9 @@ jobs:
- name: Lint Frontend
run: bun run lint
- name: Frontend Tests
run: bun run test
deploy:
runs-on: ubuntu-latest
needs: [tests, linter]

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ yarn-error.log
/.zed
.php-cs-fixer.cache
/traefik/certs/*.pem
.claude/settings.local.json

15
.release-it.json Normal file
View File

@ -0,0 +1,15 @@
{
"git": {
"commitMessage": "chore: release v${version}",
"tagName": "v${version}"
},
"npm": {
"publish": false
},
"plugins": {
"@release-it/conventional-changelog": {
"preset": "angular",
"infile": "CHANGELOG.md"
}
}
}

16
CHANGELOG.md Normal file
View File

@ -0,0 +1,16 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2025-12-28
### Added
- Initial release with end-to-end encrypted finance tracking
- Account management and bank sync via GoCardless
- Transaction categorization and labeling
- Net worth and account balance charts with multiple view modes
- PWA support for mobile installation

View File

@ -0,0 +1,84 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class DeleteUserCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:delete {email : The email address of the user to delete}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete a user and all their associated data';
/**
* Execute the console command.
*/
public function handle(): int
{
$email = $this->argument('email');
$user = User::query()->where('email', $email)->first();
if (! $user) {
$this->error("User with email '{$email}' not found.");
return self::FAILURE;
}
// Check for active subscriptions
if ($user->subscribed('default')) {
$this->error('Cannot delete user with an active subscription. Please cancel the subscription first.');
return self::FAILURE;
}
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;
}
DB::transaction(function () use ($user) {
// Delete account balances through accounts
foreach ($user->accounts as $account) {
$account->balances()->delete();
}
// 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();
// Delete user's banks
DB::table('banks')->where('user_id', $user->id)->delete();
// Delete Cashier subscription data if exists
if ($user->subscriptions()->exists()) {
$user->subscriptions()->delete();
}
// Delete the user
$user->delete();
});
$this->info("User '{$email}' and all associated data have been deleted successfully.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Console\Commands;
use App\Mail\Drip\FeedbackEmail;
use App\Mail\Drip\ImportHelpEmail;
use App\Mail\Drip\OnboardingReminderEmail;
use App\Mail\Drip\PromoCodeEmail;
use App\Mail\Drip\WelcomeEmail;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class SendTestEmails extends Command
{
protected $signature = 'email:test {email : The email address to send all test emails to}';
protected $description = 'Send all application emails to a specific email address for testing';
public function handle(): int
{
$email = $this->argument('email');
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error("Invalid email address: {$email}");
return self::FAILURE;
}
$this->info("Sending all test emails to: {$email}");
$this->newLine();
$user = User::firstOrCreate(
['email' => $email],
[
'name' => 'Test User',
'password' => bcrypt('password'),
]
);
$emails = [
'Welcome Email' => new WelcomeEmail($user),
'Feedback Email' => new FeedbackEmail($user),
'Import Help Email' => new ImportHelpEmail($user),
'Onboarding Reminder Email' => new OnboardingReminderEmail($user),
'Promo Code Email' => new PromoCodeEmail($user),
];
foreach ($emails as $name => $mailable) {
try {
Mail::to($email)->send($mailable);
$this->info("✓ Sent: {$name}");
} catch (\Exception $e) {
$this->error("✗ Failed to send {$name}: {$e->getMessage()}");
}
}
$this->newLine();
$this->info('All emails sent successfully!');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Console\Commands;
use App\Mail\UserLeadInvitation;
use App\Models\UserLead;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class SendUserLeadInvitations extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'leads:send-invitations {--force : Skip confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Send invitation emails to all UserLeads with FOUNDER promo code';
/**
* Execute the console command.
*/
public function handle(): int
{
$leads = UserLead::query()->get();
if ($leads->isEmpty()) {
$this->info('No user leads found in the database.');
return self::SUCCESS;
}
$this->info("Found {$leads->count()} user lead(s).");
if (! $this->option('force')) {
if (! $this->confirm("Do you want to send invitation emails to {$leads->count()} lead(s)?", true)) {
$this->info('Cancelled.');
return self::SUCCESS;
}
}
$this->info('Sending invitation emails...');
$progressBar = $this->output->createProgressBar($leads->count());
$progressBar->start();
$sent = 0;
foreach ($leads as $lead) {
Mail::to($lead->email)->send(new UserLeadInvitation($lead));
$sent++;
$progressBar->advance();
}
$progressBar->finish();
$this->newLine();
$this->info("Successfully queued {$sent} invitation email(s)!");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Console\Commands;
use App\Enums\DripEmailType;
use App\Jobs\Drip\SendFeedbackEmailJob;
use App\Jobs\Drip\SendImportHelpEmailJob;
use App\Jobs\Drip\SendOnboardingReminderEmailJob;
use App\Jobs\Drip\SendPromoCodeEmailJob;
use App\Jobs\Drip\SendWelcomeEmailJob;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Console\Command;
class TestScheduledDripEmailCommand extends Command
{
protected $signature = 'email:test-scheduled-drip {email} {--force : Delete existing mail logs for this user}';
protected $description = 'Test the scheduled drip email campaign for a specific user';
public function handle(): int
{
$email = $this->argument('email');
$user = User::query()->where('email', $email)->first();
if (! $user) {
$this->error("User with email '{$email}' not found.");
return self::FAILURE;
}
if ($this->option('force')) {
$deleted = UserMailLog::query()
->where('user_id', $user->id)
->whereIn('email_type', [
DripEmailType::Welcome,
DripEmailType::OnboardingReminder,
DripEmailType::PromoCode,
DripEmailType::ImportHelp,
DripEmailType::Feedback,
])
->delete();
if ($deleted > 0) {
$this->info("Deleted {$deleted} mail log(s) for user '{$email}'.");
}
}
$this->info("Scheduling drip emails for user '{$email}'...");
SendWelcomeEmailJob::dispatch($user)->delay(now()->addMinutes(30));
SendOnboardingReminderEmailJob::dispatch($user)->delay(now()->addDay());
SendPromoCodeEmailJob::dispatch($user)->delay(now()->addDay());
SendImportHelpEmailJob::dispatch($user)->delay(now()->addDay());
SendFeedbackEmailJob::dispatch($user)->delay(now()->addDays(5));
$this->info('Drip emails have been scheduled successfully!');
$this->newLine();
$this->table(
['Email Type', 'Scheduled For'],
[
['Welcome', now()->addMinutes(30)->format('Y-m-d H:i:s')],
['Onboarding Reminder', now()->addDay()->format('Y-m-d H:i:s')],
['Promo Code', now()->addDay()->format('Y-m-d H:i:s')],
['Import Help', now()->addDay()->format('Y-m-d H:i:s')],
['Feedback', now()->addDays(5)->format('Y-m-d H:i:s')],
]
);
return self::SUCCESS;
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum DripEmailType: string
{
case Welcome = 'welcome';
case OnboardingReminder = 'onboarding_reminder';
case PromoCode = 'promo_code';
case ImportHelp = 'import_help';
case Feedback = 'feedback';
}

View File

@ -44,6 +44,7 @@ class HandleInertiaRequests extends Middleware
...parent::share($request),
'name' => config('app.name'),
'appUrl' => config('app.url'),
'version' => json_decode(file_get_contents(base_path('package.json')))->version ?? '0.0.0',
'quote' => ['message' => trim($message), 'author' => trim($author)],
'auth' => [
'user' => $user,
@ -53,7 +54,7 @@ class HandleInertiaRequests extends Middleware
'pricing' => [
'plans' => config('subscriptions.plans', []),
'defaultPlan' => config('subscriptions.default_plan', 'monthly'),
'bestValuePlan' => config('subscriptions.best_value', null),
'bestValuePlan' => config('subscriptions.best_value_plan', null),
'promo' => config('subscriptions.promo', []),
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',

View File

@ -0,0 +1,43 @@
<?php
namespace App\Jobs\Drip;
use App\Enums\DripEmailType;
use App\Mail\Drip\FeedbackEmail;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendFeedbackEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user)
{
$this->onQueue('emails');
}
public function handle(): void
{
if ($this->user->hasReceivedEmail(DripEmailType::Feedback)) {
return;
}
if ($this->user->hasProPlan()) {
return;
}
Mail::to($this->user)->send(new FeedbackEmail($this->user));
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => DripEmailType::Feedback,
'sent_at' => now(),
]);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Jobs\Drip;
use App\Enums\DripEmailType;
use App\Mail\Drip\ImportHelpEmail;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendImportHelpEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user)
{
$this->onQueue('emails');
}
public function handle(): void
{
if ($this->user->hasReceivedEmail(DripEmailType::ImportHelp)) {
return;
}
if (! $this->user->isOnboarded()) {
return;
}
if ($this->user->transactions()->exists()) {
return;
}
if ($this->user->hasProPlan()) {
return;
}
Mail::to($this->user)->send(new ImportHelpEmail($this->user));
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => DripEmailType::ImportHelp,
'sent_at' => now(),
]);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Jobs\Drip;
use App\Enums\DripEmailType;
use App\Mail\Drip\OnboardingReminderEmail;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendOnboardingReminderEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user)
{
$this->onQueue('emails');
}
public function handle(): void
{
if ($this->user->hasReceivedEmail(DripEmailType::OnboardingReminder)) {
return;
}
if ($this->user->isOnboarded()) {
return;
}
Mail::to($this->user)->send(new OnboardingReminderEmail($this->user));
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => DripEmailType::OnboardingReminder,
'sent_at' => now(),
]);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Jobs\Drip;
use App\Enums\DripEmailType;
use App\Mail\Drip\PromoCodeEmail;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendPromoCodeEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user)
{
$this->onQueue('emails');
}
public function handle(): void
{
if ($this->user->hasReceivedEmail(DripEmailType::PromoCode)) {
return;
}
if (! $this->user->isOnboarded()) {
return;
}
if (! $this->user->transactions()->exists()) {
return;
}
if ($this->user->hasProPlan()) {
return;
}
Mail::to($this->user)->send(new PromoCodeEmail($this->user));
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => DripEmailType::PromoCode,
'sent_at' => now(),
]);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Jobs\Drip;
use App\Enums\DripEmailType;
use App\Mail\Drip\WelcomeEmail;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendWelcomeEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user)
{
$this->onQueue('emails');
}
public function handle(): void
{
if ($this->user->hasReceivedEmail(DripEmailType::Welcome)) {
return;
}
Mail::to($this->user)->send(new WelcomeEmail($this->user));
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => DripEmailType::Welcome,
'sent_at' => now(),
]);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Listeners;
use App\Jobs\Drip\SendFeedbackEmailJob;
use App\Jobs\Drip\SendImportHelpEmailJob;
use App\Jobs\Drip\SendOnboardingReminderEmailJob;
use App\Jobs\Drip\SendPromoCodeEmailJob;
use App\Jobs\Drip\SendWelcomeEmailJob;
use Illuminate\Auth\Events\Registered;
class ScheduleDripEmailsListener
{
public function handle(Registered $event): void
{
$user = $event->user;
SendWelcomeEmailJob::dispatch($user)->delay(now()->addMinutes(30));
SendOnboardingReminderEmailJob::dispatch($user)->delay(now()->addDay());
SendPromoCodeEmailJob::dispatch($user)->delay(now()->addDay());
SendImportHelpEmailJob::dispatch($user)->delay(now()->addDay());
SendFeedbackEmailJob::dispatch($user)->delay(now()->addDays(5));
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Mail\Drip;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class FeedbackEmail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
public function __construct(public User $user)
{
$this->onQueue('emails');
}
public function envelope(): Envelope
{
return new Envelope(
subject: "How's Your Experience So Far?",
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.feedback',
with: [
'userName' => $this->user->name,
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Mail\Drip;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class ImportHelpEmail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
public function __construct(public User $user)
{
$this->onQueue('emails');
}
public function envelope(): Envelope
{
return new Envelope(
subject: "Let's Import Your Transactions",
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.import-help',
with: [
'userName' => $this->user->name,
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Mail\Drip;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class OnboardingReminderEmail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
public function __construct(public User $user)
{
$this->onQueue('emails');
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Need Help Getting Started?',
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.onboarding-reminder',
with: [
'userName' => $this->user->name,
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Mail\Drip;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class PromoCodeEmail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
public function __construct(public User $user)
{
$this->onQueue('emails');
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Your Founder Discount - First Month for $1',
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.promo-code',
with: [
'userName' => $this->user->name,
'promoCode' => 'FOUNDER',
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Mail\Drip;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class WelcomeEmail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
public function __construct(public User $user)
{
$this->onQueue('emails');
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Welcome to Whisper Money - Your Privacy-First Finance App',
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.welcome',
with: [
'userName' => $this->user->name,
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Mail;
use App\Models\UserLead;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class UserLeadInvitation extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
/**
* Create a new message instance.
*/
public function __construct(public UserLead $lead)
{
$this->onQueue('emails');
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Your early access to Whisper Money is ready',
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'mail.user-lead-invitation',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\DripEmailType;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
@ -92,6 +93,16 @@ class User extends Authenticatable
return $this->hasMany(Label::class);
}
public function mailLogs(): HasMany
{
return $this->hasMany(UserMailLog::class);
}
public function hasReceivedEmail(DripEmailType $type): bool
{
return $this->mailLogs()->where('email_type', $type)->exists();
}
public function hasProPlan(): bool
{
if (! config('subscriptions.enabled')) {

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use App\Enums\DripEmailType;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserMailLog extends Model
{
use HasFactory, HasUuids;
protected $fillable = [
'user_id',
'email_type',
'sent_at',
];
protected function casts(): array
{
return [
'email_type' => DripEmailType::class,
'sent_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -3,6 +3,8 @@
namespace App\Providers;
use App\Http\Responses\RegisterResponse;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
@ -21,6 +23,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
RateLimiter::for('emails', function (object $job): Limit {
return Limit::perSecond(1);
});
}
}

688
bun.lock

File diff suppressed because it is too large Load Diff

View File

@ -62,6 +62,13 @@ services:
- ping
retries: 3
timeout: 5s
mailhog:
image: 'mailhog/mailhog:latest'
ports:
- '${FORWARD_MAILHOG_PORT:-1025}:1025'
- '${FORWARD_MAILHOG_DASHBOARD_PORT:-8025}:8025'
networks:
- sail
networks:
sail:
driver: bridge

View File

@ -15,7 +15,8 @@
"laravel/fortify": "^1.30",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
"laravel/wayfinder": "^0.1.9"
"laravel/wayfinder": "^0.1.9",
"resend/resend-php": "^1.1"
},
"require-dev": {
"fakerphp/faker": "^1.23",

59
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "6b2387225b885940346beca54c271107",
"content-hash": "40779ce6890f5022e5e7c7c569bd328c",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -3927,6 +3927,63 @@
},
"time": "2025-09-04T20:59:21+00:00"
},
{
"name": "resend/resend-php",
"version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/resend/resend-php.git",
"reference": "c1ce4ee5dc8ca90a5e6a2dc6d772a06abbe7c9d3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/resend/resend-php/zipball/c1ce4ee5dc8ca90a5e6a2dc6d772a06abbe7c9d3",
"reference": "c1ce4ee5dc8ca90a5e6a2dc6d772a06abbe7c9d3",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.5",
"php": "^8.1.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.13",
"mockery/mockery": "^1.6",
"pestphp/pest": "^2.0|^3.0|^4.0"
},
"type": "library",
"autoload": {
"files": [
"src/Resend.php"
],
"psr-4": {
"Resend\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Resend and contributors",
"homepage": "https://github.com/resend/resend-php/contributors"
}
],
"description": "Resend PHP library.",
"homepage": "https://resend.com/",
"keywords": [
"api",
"client",
"php",
"resend",
"sdk"
],
"support": {
"issues": "https://github.com/resend/resend-php/issues",
"source": "https://github.com/resend/resend-php/tree/v1.1.0"
},
"time": "2025-11-26T08:41:40+00:00"
},
{
"name": "stripe/stripe-php",
"version": "v17.6.0",

View File

@ -41,7 +41,7 @@ return [
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
'after_commit' => true,
],
'beanstalkd' => [

View File

@ -113,7 +113,7 @@ return [
|
*/
'best_value_plan' => null,
'best_value_plan' => 'yearly',
/*
|--------------------------------------------------------------------------

View File

@ -0,0 +1,62 @@
<?php
namespace Database\Factories;
use App\Enums\DripEmailType;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\UserMailLog>
*/
class UserMailLogFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'email_type' => fake()->randomElement(DripEmailType::cases()),
'sent_at' => now(),
];
}
public function welcome(): static
{
return $this->state(fn (array $attributes) => [
'email_type' => DripEmailType::Welcome,
]);
}
public function onboardingReminder(): static
{
return $this->state(fn (array $attributes) => [
'email_type' => DripEmailType::OnboardingReminder,
]);
}
public function promoCode(): static
{
return $this->state(fn (array $attributes) => [
'email_type' => DripEmailType::PromoCode,
]);
}
public function importHelp(): static
{
return $this->state(fn (array $attributes) => [
'email_type' => DripEmailType::ImportHelp,
]);
}
public function feedback(): static
{
return $this->state(fn (array $attributes) => [
'email_type' => DripEmailType::Feedback,
]);
}
}

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_mail_logs', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
$table->string('email_type', 50);
$table->timestamp('sent_at');
$table->timestamps();
$table->unique(['user_id', 'email_type']);
$table->index('email_type');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_mail_logs');
}
};

View File

@ -54,11 +54,22 @@ autorestart=true
stderr_logfile=/var/log/worker-inertia-ssr.log
stdout_logfile=/var/log/worker-inertia-ssr.log
# [program:horizon]
# process_name=%(program_name)s
# command=php /app/artisan horizon
# autostart=true
# autorestart=true
# redirect_stderr=true
# stdout_logfile=/var/log/horizon.log
# stopwaitsecs=3600
[program:queue-worker-emails]
process_name=%(program_name)s
command=php /app/artisan queue:work database --queue=emails --sleep=1 --tries=5 --max-time=3600
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/queue-worker-emails.log
stopwaitsecs=3600
numprocs=1
[program:queue-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /app/artisan queue:work database --queue=default --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/queue-worker.log
stopwaitsecs=3600
numprocs=2

View File

@ -1,5 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "whisper-money",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
@ -9,11 +11,18 @@
"format": "prettier --write resources/",
"format:check": "prettier --check resources/",
"lint": "eslint . --fix",
"release": "release-it",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"types": "tsc --noEmit"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@laravel/vite-plugin-wayfinder": "^0.1.3",
"@release-it/conventional-changelog": "^10.0.4",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/json-logic-js": "^2.0.8",
"@types/node": "^22.13.5",
"babel-plugin-react-compiler": "^1.0.0",
@ -21,11 +30,14 @@
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^7.0.0",
"jsdom": "^25.0.1",
"playwright": "^1.57.0",
"prettier": "^3.4.2",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.6.11",
"typescript-eslint": "^8.23.0"
"release-it": "^19.2.2",
"typescript-eslint": "^8.23.0",
"vitest": "^2.1.8"
},
"dependencies": {
"@headlessui/react": "^2.2.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -210,4 +210,12 @@
.bg-linear-to-l {
background: linear-gradient(to left, var(--tw-gradient-stops));
}
/* Charts */
.recharts-responsive-container:focus-visible,
svg:focus,
g:focus,
path:focus {
outline: none;
}
}

View File

@ -1,5 +1,11 @@
import {
ChartViewToggle,
MoMChart,
MoMPercentChart,
} from '@/components/charts';
import { PercentageTrendIndicator } from '@/components/dashboard/percentage-trend-indicator';
import { EncryptedText } from '@/components/encrypted-text';
import { AmountDisplay } from '@/components/ui/amount-display';
import {
Card,
CardContent,
@ -13,9 +19,13 @@ import {
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import {
convertSingleAccountData,
useChartViews,
} from '@/hooks/use-chart-views';
import { Account } from '@/types/account';
import { format, subMonths } from 'date-fns';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Bar, BarChart, XAxis } from 'recharts';
interface BalanceDataPoint {
@ -145,6 +155,23 @@ export function AccountBalanceChart({
};
}, [balanceData]);
// Convert data for useChartViews hook
const { data: hookData, accounts: hookAccounts } = useMemo(() => {
return convertSingleAccountData(
chartData,
account.id,
account.type,
account.currency_code,
);
}, [chartData, account.id, account.type, account.currency_code]);
const chartViews = useChartViews({
data: hookData,
accounts: hookAccounts,
initialView: 'stacked',
hasStackedView: true,
});
const chartConfig: ChartConfig = {
value: {
label: (
@ -162,6 +189,17 @@ export function AccountBalanceChart({
return formatCurrency(value, account.currency_code);
};
const scrollContainerRef = useRef<HTMLDivElement>(null);
const minBarWidth = 50;
const minChartWidth = chartData.length * minBarWidth;
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollLeft =
scrollContainerRef.current.scrollWidth;
}
}, [chartData]);
if (initialLoading || isLoading) {
return (
<Card>
@ -194,11 +232,23 @@ export function AccountBalanceChart({
}
return (
<Card>
<Card className="group">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<div className="flex flex-row items-start justify-between">
<div className="flex flex-col gap-1 sm:gap-2">
<CardTitle>Balance evolution</CardTitle>
<button
type="button"
onClick={onBalanceClick}
className="-ml-3 cursor-pointer rounded-md px-2 py-1 text-left text-4xl font-semibold tabular-nums transition-colors hover:bg-muted"
>
<AmountDisplay
amountInCents={currentBalance}
currencyCode={account.currency_code}
minimumFractionDigits={0}
maximumFractionDigits={0}
/>
</button>
<CardDescription className="flex flex-col gap-1 text-sm">
<PercentageTrendIndicator
trend={monthlyTrend?.percentage ?? null}
@ -216,48 +266,67 @@ export function AccountBalanceChart({
/>
</CardDescription>
</div>
<div>
<button
type="button"
onClick={onBalanceClick}
className="-mr-2 cursor-pointer rounded-md px-2 py-1 text-4xl font-semibold tabular-nums transition-colors hover:bg-muted"
>
{formatCurrency(
currentBalance,
account.currency_code,
)}
</button>
</div>
<ChartViewToggle
value={chartViews.currentView}
onValueChange={chartViews.setCurrentView}
availableViews={chartViews.availableViews}
/>
</div>
</CardHeader>
<CardContent>
<ChartContainer
config={chartConfig}
className="h-[300px] w-full"
>
<BarChart accessibilityLayer data={chartData}>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisLabel}
/>
<ChartTooltip
content={
<ChartTooltipContent
hideLabel
valueFormatter={valueFormatter}
<CardContent className="relative">
{chartViews.currentView === 'stacked' && (
<div
ref={scrollContainerRef}
className="h-[300px] w-full overflow-x-auto"
>
<ChartContainer
config={chartConfig}
className="h-full w-full"
style={{ minWidth: `${minChartWidth}px` }}
>
<BarChart
accessibilityLayer
data={chartData.slice(1)}
>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisLabel}
/>
}
/>
<Bar
dataKey="value"
fill="var(--color-chart-2)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
<ChartTooltip
content={
<ChartTooltipContent
hideLabel
valueFormatter={valueFormatter}
/>
}
/>
<Bar
dataKey="value"
fill="var(--color-chart-2)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
</div>
)}
{chartViews.currentView === 'mom' && (
<MoMChart
data={chartViews.deltaSeries}
currencyCode={account.currency_code}
xAxisFormatter={formatXAxisLabel}
className="h-[300px] w-full"
/>
)}
{chartViews.currentView === 'mom_percent' && (
<MoMPercentChart
data={chartViews.momPercentSeries}
xAxisFormatter={formatXAxisLabel}
className="h-[300px] w-full"
/>
)}
</CardContent>
</Card>
);

View File

@ -187,7 +187,7 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
</TooltipProvider>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogContent hasKeyboard className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create Account</DialogTitle>
<DialogDescription>

View File

@ -200,7 +200,7 @@ export function EditAccountDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent hasKeyboard className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Account</DialogTitle>
<DialogDescription>

View File

@ -92,7 +92,7 @@ export function UpdateBalanceDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[400px]">
<DialogContent hasKeyboard className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Update balance</DialogTitle>
<DialogDescription>

View File

@ -44,7 +44,7 @@ export function CreateCategoryDialog({
<DialogTrigger asChild>
<Button>Create Category</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogContent hasKeyboard className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create Category</DialogTitle>
<DialogDescription>

View File

@ -48,7 +48,7 @@ export function EditCategoryDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent hasKeyboard className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Category</DialogTitle>
<DialogDescription>

View File

@ -0,0 +1,74 @@
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { ChartViewType } from '@/hooks/use-chart-views';
import { cn } from '@/lib/utils';
import { BarChart3, Percent, TrendingUp } from 'lucide-react';
interface ChartViewToggleProps {
value: ChartViewType;
onValueChange: (value: ChartViewType) => void;
availableViews: ChartViewType[];
className?: string;
}
const viewConfig: Record<
ChartViewType,
{ icon: React.ElementType; label: string; tooltip: string }
> = {
stacked: {
icon: BarChart3,
label: 'Aggregate',
tooltip: 'Monthly balance',
},
mom: { icon: TrendingUp, label: 'MoM', tooltip: 'Month over month change' },
mom_percent: {
icon: Percent,
label: 'MoM%',
tooltip: 'Month over month change (%)',
},
};
export function ChartViewToggle({
value,
onValueChange,
availableViews,
className,
}: ChartViewToggleProps) {
return (
<ToggleGroup
type="single"
value={value}
onValueChange={(v) => {
if (v) onValueChange(v as ChartViewType);
}}
variant="outline"
size="sm"
className={cn('', className)}
>
{availableViews.map((view) => {
const config = viewConfig[view];
const Icon = config.icon;
return (
<Tooltip key={view}>
<TooltipTrigger asChild>
<ToggleGroupItem
value={view}
aria-label={config.label}
className="px-2"
>
<Icon className="size-3.5" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="bottom">
{config.tooltip}
</TooltipContent>
</Tooltip>
);
})}
</ToggleGroup>
);
}

View File

@ -0,0 +1,3 @@
export { ChartViewToggle } from './chart-view-toggle';
export { MoMChart } from './mom-chart';
export { MoMPercentChart } from './mom-percent-chart';

View File

@ -0,0 +1,154 @@
import { AmountDisplay } from '@/components/ui/amount-display';
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
import { ChangeDataPoint } from '@/lib/chart-calculations';
import { cn } from '@/lib/utils';
import { useEffect, useRef } from 'react';
import {
Bar,
BarChart,
Cell,
ReferenceLine,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
interface MoMChartProps {
data: ChangeDataPoint[];
currencyCode: string;
xAxisFormatter?: (value: string) => string;
className?: string;
minBarWidth?: number;
}
const chartConfig: ChartConfig = {
change: {
label: 'Change',
color: 'var(--color-chart-1)',
},
};
interface TooltipPayloadItem {
payload?: {
month?: string;
value?: number;
change?: number | null;
};
}
interface CustomTooltipProps {
active?: boolean;
payload?: TooltipPayloadItem[];
currencyCode: string;
}
function CustomTooltip({ active, payload, currencyCode }: CustomTooltipProps) {
if (!active || !payload?.length) return null;
const data = payload[0].payload;
if (!data) return null;
const change = data.change;
return (
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<div className="font-medium">{data.month}</div>
<div className="mt-1 flex items-center justify-between gap-4">
<span className="text-muted-foreground">Change</span>
<span
className={cn(
'font-mono font-medium tabular-nums',
change !== null && change > 0 && 'text-emerald-600',
change !== null && change < 0 && 'text-red-600',
)}
>
<AmountDisplay
amountInCents={change ?? 0}
currencyCode={currencyCode}
minimumFractionDigits={0}
maximumFractionDigits={0}
/>
</span>
</div>
</div>
);
}
export function MoMChart({
data,
currencyCode,
xAxisFormatter,
className,
minBarWidth = 50,
}: MoMChartProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const minChartWidth = data.length * minBarWidth;
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollLeft =
scrollContainerRef.current.scrollWidth;
}
}, [data]);
const chartData = data.map((point) => ({
...point,
displayValue: point.change,
}));
return (
<div
ref={scrollContainerRef}
className={cn('overflow-x-auto', className)}
>
<ChartContainer
config={chartConfig}
className="h-full w-full"
style={{ minWidth: `${minChartWidth}px` }}
>
<BarChart accessibilityLayer data={chartData}>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={xAxisFormatter}
/>
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => {
return new Intl.NumberFormat('en-US', {
notation: 'compact',
compactDisplay: 'short',
}).format(value / 100);
}}
width={50}
/>
<ReferenceLine
y={0}
stroke="var(--color-border)"
strokeDasharray="3 3"
/>
<Tooltip
content={<CustomTooltip currencyCode={currencyCode} />}
cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }}
/>
<Bar dataKey="displayValue" radius={[4, 4, 0, 0]}>
{chartData.map((entry, index) => {
const value = entry.displayValue;
let fill = 'var(--color-muted)';
if (value !== null) {
fill =
value >= 0
? 'var(--color-chart-2)'
: 'var(--color-chart-5)';
}
return <Cell key={`cell-${index}`} fill={fill} />;
})}
</Bar>
</BarChart>
</ChartContainer>
</div>
);
}

View File

@ -0,0 +1,142 @@
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
import { formatPercentValue, PercentDataPoint } from '@/lib/chart-calculations';
import { cn } from '@/lib/utils';
import { useEffect, useRef } from 'react';
import {
Bar,
BarChart,
Cell,
ReferenceLine,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
interface MoMPercentChartProps {
data: PercentDataPoint[];
xAxisFormatter?: (value: string) => string;
className?: string;
minBarWidth?: number;
}
const chartConfig: ChartConfig = {
percent: {
label: 'Change %',
color: 'var(--color-chart-1)',
},
};
interface TooltipPayloadItem {
payload?: {
month?: string;
value?: number;
percent?: number | null;
};
}
interface CustomTooltipProps {
active?: boolean;
payload?: TooltipPayloadItem[];
}
function CustomTooltip({ active, payload }: CustomTooltipProps) {
if (!active || !payload?.length) return null;
const data = payload[0].payload;
if (!data) return null;
const percent = data.percent;
return (
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<div className="font-medium">{data.month}</div>
<div className="mt-1 flex items-center justify-between gap-4">
<span className="text-muted-foreground">Change</span>
<span
className={cn(
'font-mono font-medium tabular-nums',
percent !== null && percent > 0 && 'text-emerald-600',
percent !== null && percent < 0 && 'text-red-600',
)}
>
{formatPercentValue(percent ?? null)}
</span>
</div>
</div>
);
}
export function MoMPercentChart({
data,
xAxisFormatter,
className,
minBarWidth = 50,
}: MoMPercentChartProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const minChartWidth = data.length * minBarWidth;
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollLeft =
scrollContainerRef.current.scrollWidth;
}
}, [data]);
const chartData = data.map((point) => ({
...point,
displayValue: point.percent,
}));
return (
<div
ref={scrollContainerRef}
className={cn('overflow-x-auto', className)}
>
<ChartContainer
config={chartConfig}
className="h-full w-full"
style={{ minWidth: `${minChartWidth}px` }}
>
<BarChart accessibilityLayer data={chartData}>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={xAxisFormatter}
/>
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={(value: number) =>
`${value.toFixed(0)}%`
}
width={50}
/>
<ReferenceLine
y={0}
stroke="var(--color-border)"
strokeDasharray="3 3"
/>
<Tooltip
content={<CustomTooltip />}
cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }}
/>
<Bar dataKey="displayValue" radius={[4, 4, 0, 0]}>
{chartData.map((entry, index) => {
const value = entry.displayValue;
let fill = 'var(--color-muted)';
if (value !== null) {
fill =
value >= 0
? 'var(--color-chart-2)'
: 'var(--color-chart-5)';
}
return <Cell key={`cell-${index}`} fill={fill} />;
})}
</Bar>
</BarChart>
</ChartContainer>
</div>
);
}

View File

@ -1,3 +1,8 @@
import {
ChartViewToggle,
MoMChart,
MoMPercentChart,
} from '@/components/charts';
import { EncryptedText } from '@/components/encrypted-text';
import { AmountDisplay } from '@/components/ui/amount-display';
import {
@ -9,7 +14,9 @@ import {
} from '@/components/ui/card';
import { ChartConfig } from '@/components/ui/chart';
import { StackedBarChart } from '@/components/ui/stacked-bar-chart';
import { useChartViews } from '@/hooks/use-chart-views';
import { NetWorthEvolutionData } from '@/hooks/use-dashboard-data';
import { AccountInfo } from '@/lib/chart-calculations';
import { useMemo } from 'react';
import { PercentageTrendIndicator } from './percentage-trend-indicator';
@ -140,6 +147,7 @@ export function NetWorthChart({
currencyTotals,
accountCurrencies,
primaryCurrency,
accountsForHook,
} = useMemo(() => {
const accounts = data.accounts || {};
const accountIds = Object.keys(accounts);
@ -147,6 +155,7 @@ export function NetWorthChart({
const config: ChartConfig = {};
const currencies: Record<string, string> = {};
const hookAccounts: Record<string, AccountInfo> = {};
accountIds.forEach((id) => {
const account = accounts[id];
@ -156,6 +165,13 @@ export function NetWorthChart({
if (account?.currency_code) {
currencies[id] = account.currency_code;
}
if (account) {
hookAccounts[id] = {
id: account.id,
type: account.type,
currency_code: account.currency_code,
};
}
});
const totals: Record<string, number> = {};
@ -192,9 +208,17 @@ export function NetWorthChart({
currencyTotals: currencyTotalsList,
accountCurrencies: currencies,
primaryCurrency: primary,
accountsForHook: hookAccounts,
};
}, [data]);
const chartViews = useChartViews({
data: chartData,
accounts: accountsForHook,
initialView: 'stacked',
hasStackedView: true,
});
const valueFormatter = useMemo(() => {
return (value: number, accountId?: string): React.ReactNode => {
const currency =
@ -244,12 +268,15 @@ export function NetWorthChart({
}
return (
<Card className="overflow-hidden">
<Card className="group overflow-hidden">
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-row items-start justify-between gap-4">
<div className="flex min-w-0 flex-col gap-2">
<CardTitle>Net Worth Evolution</CardTitle>
<CardDescription className="flex flex-col gap-1 text-sm">
<div className="text-foreground">
<TotalDisplay totals={currencyTotals} />
</div>
<PercentageTrendIndicator
trend={monthlyTrend?.percentage ?? null}
label="this month"
@ -267,23 +294,42 @@ export function NetWorthChart({
</CardDescription>
</div>
<div className="shrink-0">
<TotalDisplay totals={currencyTotals} />
</div>
<ChartViewToggle
value={chartViews.currentView}
onValueChange={chartViews.setCurrentView}
availableViews={chartViews.availableViews}
/>
</div>
</CardHeader>
<CardContent className="min-w-0">
<StackedBarChart
data={chartData}
dataKeys={dataKeys}
config={chartConfig}
xAxisKey="month"
xAxisFormatter={formatXAxisLabel}
valueFormatter={valueFormatter}
accountCurrencies={accountCurrencies}
className="h-[300px] w-full"
showLegend={showLegend}
/>
<CardContent className="relative min-w-0">
{chartViews.currentView === 'stacked' && (
<StackedBarChart
data={chartData.slice(1)}
dataKeys={dataKeys}
config={chartConfig}
xAxisKey="month"
xAxisFormatter={formatXAxisLabel}
valueFormatter={valueFormatter}
accountCurrencies={accountCurrencies}
className="h-[300px] w-full"
showLegend={showLegend}
/>
)}
{chartViews.currentView === 'mom' && (
<MoMChart
data={chartViews.deltaSeries}
currencyCode={primaryCurrency}
xAxisFormatter={formatXAxisLabel}
className="h-[300px] w-full"
/>
)}
{chartViews.currentView === 'mom_percent' && (
<MoMPercentChart
data={chartViews.momPercentSeries}
xAxisFormatter={formatXAxisLabel}
className="h-[300px] w-full"
/>
)}
</CardContent>
</Card>
);

View File

@ -31,7 +31,7 @@ export function CreateLabelDialog({ onSuccess }: { onSuccess?: () => void }) {
<DialogTrigger asChild>
<Button>Create Label</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogContent hasKeyboard className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create Label</DialogTitle>
<DialogDescription>

View File

@ -40,7 +40,7 @@ export function EditLabelDialog({
}: EditLabelDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent hasKeyboard className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Label</DialogTitle>
<DialogDescription>

View File

@ -103,8 +103,8 @@ export function TransactionFilters({
return (
<div className="space-y-4">
<div className="flex flex-col items-center gap-3 sm:flex-row">
<div className="flex w-full flex-row items-center gap-2 sm:w-auto">
<div className="flex flex-col items-center gap-3 lg:flex-row">
<div className="flex w-full flex-row items-center gap-2 lg:w-auto">
<Input
placeholder={
isKeySet
@ -119,7 +119,7 @@ export function TransactionFilters({
})
}
disabled={!isKeySet}
className="max-w-sm flex-1 sm:min-w-[200px] md:min-w-[300px]"
className="max-w-sm flex-1 md:max-w-full"
/>
<Popover open={isOpen} onOpenChange={setIsOpen}>

View File

@ -50,9 +50,11 @@ function DialogContent({
className,
children,
showCloseButton = true,
hasKeyboard = false,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
showCloseButton?: boolean,
hasKeyboard?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
@ -60,7 +62,9 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-1.5rem)] max-h-[95vh] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 overflow-y-auto sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] z-50 grid w-full max-w-[calc(100%-1.5rem)] max-h-[95vh] translate-x-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 overflow-y-auto sm:max-w-lg",
hasKeyboard && "top-[10%] sm:top-[50%] sm:translate-y-[-50%]",
!hasKeyboard && "top-[50%] translate-y-[-50%]",
className
)}
{...props}

View File

@ -104,7 +104,7 @@ export default function UnlockMessageDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent hasKeyboard>
<DialogHeader>
<DialogTitle>Unlock Encrypted Message</DialogTitle>
<DialogDescription>

View File

@ -11,8 +11,8 @@ import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
import { clearKey } from '@/lib/key-storage';
import { logout } from '@/routes';
import accounts from '@/routes/accounts';
import { type User } from '@/types';
import { Link, router } from '@inertiajs/react';
import { type SharedData, type User } from '@/types';
import { Link, router, usePage } from '@inertiajs/react';
import { Eye, EyeOff, LogOut, Settings } from 'lucide-react';
interface UserMenuContentProps {
@ -22,6 +22,7 @@ interface UserMenuContentProps {
export function UserMenuContent({ user }: UserMenuContentProps) {
const cleanup = useMobileNavigation();
const { isPrivacyModeEnabled, togglePrivacyMode } = usePrivacyMode();
const { version } = usePage<SharedData>().props;
const handleLogout = () => {
clearKey();
@ -86,6 +87,10 @@ export function UserMenuContent({ user }: UserMenuContentProps) {
Log out
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-center text-xs text-muted-foreground">
v{version}
</div>
</>
);
}

View File

@ -0,0 +1,130 @@
import {
AccountInfo,
ChangeDataPoint,
computeDeltaSeries,
computeMoMPercent,
computeNetWorthSeries,
MonthDataPoint,
PercentDataPoint,
} from '@/lib/chart-calculations';
import { useCallback, useMemo, useState } from 'react';
export type ChartViewType = 'stacked' | 'mom' | 'mom_percent';
export interface UseChartViewsOptions {
/** Raw monthly data with account balances */
data: Array<Record<string, string | number>>;
/** Account information keyed by account ID */
accounts: Record<string, AccountInfo>;
/** Initial view type */
initialView?: ChartViewType;
/** Whether to show stacked view option (false for single account charts) */
hasStackedView?: boolean;
}
export interface UseChartViewsReturn {
// View state
currentView: ChartViewType;
setCurrentView: (view: ChartViewType) => void;
availableViews: ChartViewType[];
// Computed series
netWorthSeries: MonthDataPoint[];
deltaSeries: ChangeDataPoint[];
momPercentSeries: PercentDataPoint[];
}
/**
* Hook for managing chart view state and computed series
* Shared between net-worth-chart and account-balance-chart
*/
export function useChartViews({
data,
accounts,
initialView = 'stacked',
hasStackedView = true,
}: UseChartViewsOptions): UseChartViewsReturn {
// View state
const [currentView, setCurrentViewState] = useState<ChartViewType>(
hasStackedView
? initialView
: initialView === 'stacked'
? 'mom'
: initialView,
);
// Available views based on chart type
const availableViews = useMemo<ChartViewType[]>(() => {
if (hasStackedView) {
return ['stacked', 'mom', 'mom_percent'];
}
return ['mom', 'mom_percent'];
}, [hasStackedView]);
// Set current view with validation
const setCurrentView = useCallback(
(view: ChartViewType) => {
if (availableViews.includes(view)) {
setCurrentViewState(view);
}
},
[availableViews],
);
// Compute all chart series in a single memoized block
// The first month is needed for MoM calculations but shouldn't be displayed,
// so we slice(1) to show only 12 bars
const { netWorthSeries, deltaSeries, momPercentSeries } = useMemo(() => {
const fullNetWorth = computeNetWorthSeries(data, accounts);
const fullDelta = computeDeltaSeries(fullNetWorth);
const fullMomPercent = computeMoMPercent(fullNetWorth);
return {
netWorthSeries: fullNetWorth.slice(1),
deltaSeries: fullDelta.slice(1),
momPercentSeries: fullMomPercent.slice(1),
};
}, [data, accounts]);
return {
// View state
currentView,
setCurrentView,
availableViews,
// Computed series (with first month removed)
netWorthSeries,
deltaSeries,
momPercentSeries,
};
}
/**
* Convert single-account balance data to the format expected by useChartViews
* For use in account-balance-chart
*/
export function convertSingleAccountData(
balanceData: Array<{ month: string; timestamp?: number; value: number }>,
accountId: string,
accountType: string,
currencyCode: string,
): {
data: Array<Record<string, string | number>>;
accounts: Record<string, AccountInfo>;
} {
const data = balanceData.map((point) => ({
month: point.month,
timestamp: point.timestamp ?? 0,
[accountId]: point.value,
}));
const accounts: Record<string, AccountInfo> = {
[accountId]: {
id: accountId,
type: accountType as AccountInfo['type'],
currency_code: currencyCode,
},
};
return { data, accounts };
}

View File

@ -0,0 +1,320 @@
import { describe, expect, it } from 'vitest';
import {
AccountInfo,
computeDeltaSeries,
computeMoMPercent,
computeNetWorthSeries,
formatPercentValue,
getAccountSign,
isLiabilityType,
MonthDataPoint,
} from './chart-calculations';
describe('isLiabilityType', () => {
it('returns true for credit_card', () => {
expect(isLiabilityType('credit_card')).toBe(true);
});
it('returns true for loan', () => {
expect(isLiabilityType('loan')).toBe(true);
});
it('returns false for checking', () => {
expect(isLiabilityType('checking')).toBe(false);
});
it('returns false for savings', () => {
expect(isLiabilityType('savings')).toBe(false);
});
it('returns false for investment', () => {
expect(isLiabilityType('investment')).toBe(false);
});
it('returns false for retirement', () => {
expect(isLiabilityType('retirement')).toBe(false);
});
it('returns false for others', () => {
expect(isLiabilityType('others')).toBe(false);
});
});
describe('getAccountSign', () => {
it('returns -1 for liabilities', () => {
expect(getAccountSign('credit_card')).toBe(-1);
expect(getAccountSign('loan')).toBe(-1);
});
it('returns 1 for assets', () => {
expect(getAccountSign('checking')).toBe(1);
expect(getAccountSign('savings')).toBe(1);
expect(getAccountSign('investment')).toBe(1);
expect(getAccountSign('retirement')).toBe(1);
expect(getAccountSign('others')).toBe(1);
});
});
describe('computeNetWorthSeries', () => {
const createAccounts = (
types: Record<string, 'checking' | 'savings' | 'credit_card' | 'loan'>,
): Record<string, AccountInfo> => {
const accounts: Record<string, AccountInfo> = {};
for (const [id, type] of Object.entries(types)) {
accounts[id] = { id, type, currency_code: 'EUR' };
}
return accounts;
};
it('returns empty array for empty data', () => {
const result = computeNetWorthSeries([], {});
expect(result).toEqual([]);
});
it('sums assets correctly', () => {
const data = [
{ month: '2025-01', acc1: 10000, acc2: 20000 },
{ month: '2025-02', acc1: 15000, acc2: 25000 },
];
const accounts = createAccounts({ acc1: 'checking', acc2: 'savings' });
const result = computeNetWorthSeries(data, accounts);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ month: '2025-01', value: 30000 });
expect(result[1]).toEqual({ month: '2025-02', value: 40000 });
});
it('subtracts liabilities from net worth', () => {
const data = [
{ month: '2025-01', checking: 50000, credit_card: 10000 },
];
const accounts = createAccounts({
checking: 'checking',
credit_card: 'credit_card',
});
const result = computeNetWorthSeries(data, accounts);
expect(result[0].value).toBe(40000); // 50000 - 10000
});
it('handles mixed assets and liabilities', () => {
const data = [
{
month: '2025-01',
savings: 100000,
checking: 20000,
credit_card: 5000,
loan: 30000,
},
];
const accounts = createAccounts({
savings: 'savings',
checking: 'checking',
credit_card: 'credit_card',
loan: 'loan',
});
const result = computeNetWorthSeries(data, accounts);
// Net worth = 100000 + 20000 - 5000 - 30000 = 85000
expect(result[0].value).toBe(85000);
});
it('handles negative net worth (more liabilities than assets)', () => {
const data = [{ month: '2025-01', checking: 10000, loan: 50000 }];
const accounts = createAccounts({ checking: 'checking', loan: 'loan' });
const result = computeNetWorthSeries(data, accounts);
expect(result[0].value).toBe(-40000); // 10000 - 50000
});
it('preserves timestamp if present', () => {
const data = [{ month: '2025-01', timestamp: 1704067200, acc1: 10000 }];
const accounts = createAccounts({ acc1: 'checking' });
const result = computeNetWorthSeries(data, accounts);
expect(result[0].timestamp).toBe(1704067200);
});
it('skips non-numeric balance values', () => {
const data = [{ month: '2025-01', acc1: 10000, acc2: 'invalid' }];
const accounts = createAccounts({ acc1: 'checking', acc2: 'savings' });
const result = computeNetWorthSeries(data, accounts);
expect(result[0].value).toBe(10000);
});
});
describe('computeDeltaSeries', () => {
it('returns null change for first month', () => {
const series: MonthDataPoint[] = [{ month: '2025-01', value: 10000 }];
const result = computeDeltaSeries(series);
expect(result[0].change).toBeNull();
});
it('computes correct positive delta', () => {
const series: MonthDataPoint[] = [
{ month: '2025-01', value: 10000 },
{ month: '2025-02', value: 15000 },
];
const result = computeDeltaSeries(series);
expect(result[1].change).toBe(5000);
});
it('computes correct negative delta', () => {
const series: MonthDataPoint[] = [
{ month: '2025-01', value: 15000 },
{ month: '2025-02', value: 10000 },
];
const result = computeDeltaSeries(series);
expect(result[1].change).toBe(-5000);
});
});
describe('computeMoMPercent', () => {
it('returns null for first month', () => {
const series: MonthDataPoint[] = [{ month: '2025-01', value: 10000 }];
const result = computeMoMPercent(series);
expect(result[0].percent).toBeNull();
});
it('computes correct positive percentage', () => {
const series: MonthDataPoint[] = [
{ month: '2025-01', value: 10000 },
{ month: '2025-02', value: 11000 },
];
const result = computeMoMPercent(series);
expect(result[1].percent).toBe(10); // +10%
});
it('computes correct negative percentage', () => {
const series: MonthDataPoint[] = [
{ month: '2025-01', value: 10000 },
{ month: '2025-02', value: 9000 },
];
const result = computeMoMPercent(series);
expect(result[1].percent).toBe(-10); // -10%
});
it('returns null when previous value is zero (division by zero guard)', () => {
const series: MonthDataPoint[] = [
{ month: '2025-01', value: 0 },
{ month: '2025-02', value: 10000 },
];
const result = computeMoMPercent(series);
expect(result[1].percent).toBeNull();
});
it('handles negative to positive transition correctly', () => {
const series: MonthDataPoint[] = [
{ month: '2025-01', value: -10000 },
{ month: '2025-02', value: 5000 },
];
const result = computeMoMPercent(series);
// Change of 15000 relative to absolute value of -10000 = 150%
expect(result[1].percent).toBe(150);
});
});
describe('edge cases for slice(1) in useChartViews hook', () => {
// The hook slices off the first month (needed for MoM calculation baseline)
// These tests document expected behavior for edge cases
it('single month data results in empty series after slice', () => {
const data = [{ month: '2025-01', acc1: 10000 }];
const accounts: Record<string, AccountInfo> = {
acc1: { id: 'acc1', type: 'checking', currency_code: 'EUR' },
};
const netWorth = computeNetWorthSeries(data, accounts);
const delta = computeDeltaSeries(netWorth);
const momPercent = computeMoMPercent(netWorth);
// All series have 1 element before slicing
expect(netWorth).toHaveLength(1);
expect(delta).toHaveLength(1);
expect(momPercent).toHaveLength(1);
// After slice(1), all become empty
expect(netWorth.slice(1)).toHaveLength(0);
expect(delta.slice(1)).toHaveLength(0);
expect(momPercent.slice(1)).toHaveLength(0);
});
it('two months data results in single data point after slice', () => {
const data = [
{ month: '2025-01', acc1: 10000 },
{ month: '2025-02', acc1: 15000 },
];
const accounts: Record<string, AccountInfo> = {
acc1: { id: 'acc1', type: 'checking', currency_code: 'EUR' },
};
const netWorth = computeNetWorthSeries(data, accounts);
const delta = computeDeltaSeries(netWorth);
const momPercent = computeMoMPercent(netWorth);
// After slice(1), we have 1 displayable data point
expect(netWorth.slice(1)).toHaveLength(1);
expect(delta.slice(1)).toHaveLength(1);
expect(momPercent.slice(1)).toHaveLength(1);
// And the values are computed correctly
expect(netWorth.slice(1)[0].value).toBe(15000);
expect(delta.slice(1)[0].change).toBe(5000);
expect(momPercent.slice(1)[0].percent).toBe(50);
});
it('empty data results in empty series', () => {
const netWorth = computeNetWorthSeries([], {});
const delta = computeDeltaSeries(netWorth);
const momPercent = computeMoMPercent(netWorth);
expect(netWorth.slice(1)).toHaveLength(0);
expect(delta.slice(1)).toHaveLength(0);
expect(momPercent.slice(1)).toHaveLength(0);
});
});
describe('formatPercentValue', () => {
it('returns dash for null values', () => {
expect(formatPercentValue(null)).toBe('—');
});
it('adds plus sign for positive values', () => {
expect(formatPercentValue(10.5)).toBe('+10.5%');
});
it('does not add sign for negative values', () => {
expect(formatPercentValue(-5.3)).toBe('-5.3%');
});
it('formats zero without sign', () => {
expect(formatPercentValue(0)).toBe('0.0%');
});
it('respects custom decimal places', () => {
expect(formatPercentValue(10.567, { decimals: 2 })).toBe('+10.57%');
});
});

View File

@ -0,0 +1,150 @@
import { AccountType } from '@/types/account';
/**
* Account types that reduce net worth (liabilities)
*/
export const LIABILITY_TYPES: AccountType[] = ['credit_card', 'loan'];
/**
* Check if an account type is a liability
*/
export function isLiabilityType(type: AccountType): boolean {
return LIABILITY_TYPES.includes(type);
}
/**
* Get the sign multiplier for an account type
* Assets are positive (+1), Liabilities are negative (-1)
*/
export function getAccountSign(type: AccountType): 1 | -1 {
return isLiabilityType(type) ? -1 : 1;
}
/**
* Data point representing net worth for a month
*/
export interface MonthDataPoint {
month: string;
timestamp?: number;
value: number;
}
/**
* Data point with percentage change
*/
export interface PercentDataPoint extends MonthDataPoint {
percent: number | null;
}
/**
* Data point with absolute change
*/
export interface ChangeDataPoint extends MonthDataPoint {
change: number | null;
}
/**
* Account info for calculations
*/
export interface AccountInfo {
id: string;
type: AccountType;
currency_code: string;
}
/**
* Compute net worth series from raw balance data
* Handles sign correctly: assets add to NW, liabilities subtract
*/
export function computeNetWorthSeries(
data: Array<Record<string, string | number>>,
accounts: Record<string, AccountInfo>,
): MonthDataPoint[] {
if (!data || data.length === 0) {
return [];
}
const accountIds = Object.keys(accounts);
return data.map((point) => {
const month = point.month as string;
const timestamp = point.timestamp as number | undefined;
const totalNetWorth = accountIds.reduce((sum, id) => {
const balance = point[id];
if (typeof balance !== 'number') return sum;
const account = accounts[id];
if (!account) return sum + balance;
const sign = getAccountSign(account.type);
return sum + sign * balance;
}, 0);
return {
month,
timestamp,
value: totalNetWorth,
};
});
}
/**
* Compute delta series (change from previous month)
*/
export function computeDeltaSeries(
nwSeries: MonthDataPoint[],
): ChangeDataPoint[] {
return nwSeries.map((point, index) => {
if (index === 0) {
return { ...point, change: null };
}
const previousValue = nwSeries[index - 1].value;
return {
...point,
change: point.value - previousValue,
};
});
}
/**
* Compute month-over-month percentage change
*/
export function computeMoMPercent(
nwSeries: MonthDataPoint[],
): PercentDataPoint[] {
return nwSeries.map((point, index) => {
if (index === 0) {
return { ...point, percent: null };
}
const previousValue = nwSeries[index - 1].value;
// Guard against division by zero or undefined previous value
if (previousValue === 0) {
return { ...point, percent: null };
}
const percentChange =
((point.value - previousValue) / Math.abs(previousValue)) * 100;
return { ...point, percent: percentChange };
});
}
/**
* Format percentage value for display
*/
export function formatPercentValue(
value: number | null,
options?: { decimals?: number },
): string {
if (value === null) return '—';
const decimals = options?.decimals ?? 1;
const formatted = value.toFixed(decimals);
const sign = value > 0 ? '+' : '';
return `${sign}${formatted}%`;
}

View File

@ -1,3 +1,4 @@
export const LEAD_FUNNEL_EVENT_UUID = '9668a06c-dee9-47a8-9bee-eaaa2a3a5915';
export const IMPORT_FUNNEL_EVENT_UUID = 'eea25dce-75ef-4959-b975-a7e9094b3ef5';
export const ONBOARDING_FUNNEL_EVENT_UUID = '8a10c1da-080b-44cc-8dcf-78734b29106c';
export const ONBOARDING_FUNNEL_EVENT_UUID =
'8a10c1da-080b-44cc-8dcf-78734b29106c';

View File

@ -1,7 +1,5 @@
import InputError from '@/components/input-error';
import Header from '@/components/partials/header';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Tooltip,
TooltipContent,
@ -11,15 +9,18 @@ import {
import { LEAD_FUNNEL_EVENT_UUID } from '@/lib/constants';
import { trackEvent } from '@/lib/track-event';
import { cn } from '@/lib/utils';
import { store } from '@/routes/user-leads';
import { type SharedData } from '@/types';
import { Plan } from '@/types/pricing';
import { Form, Head, Link, usePage } from '@inertiajs/react';
import { Head, Link, usePage } from '@inertiajs/react';
import {
BellIcon,
BrainIcon,
Building2Icon,
CheckIcon,
CodeIcon,
EyeOffIcon,
FileUpIcon,
KeyIcon,
LockIcon,
PieChartIcon,
ShieldCheckIcon,
@ -40,8 +41,6 @@ function LandingPlanCard({
plan,
isDefault,
isBestValue,
promoEnabled,
promoBadge,
}: {
plan: Plan;
isDefault: boolean;
@ -68,16 +67,16 @@ function LandingPlanCard({
Best Value
</div>
)}
<div className="absolute -top-12 -right-12 h-32 w-32 rounded-full bg-gradient-to-br from-emerald-500/20 to-teal-500/20 blur-3xl" />
<div
className={cn(
'absolute -top-12 -right-12 h-32 w-32 rounded-full bg-gradient-to-br from-emerald-500/20 to-teal-500/20 blur-3xl',
isBestValue && 'from-blue-500/20 to-cyan-500/20',
)}
/>
<div className="relative flex flex-1 flex-col p-6">
<div className="flex items-center gap-3">
<h3 className="text-xl font-semibold">{plan.name}</h3>
{promoEnabled && isDefault && (
<div className="inline-block rounded-lg bg-emerald-100 px-2 py-1 text-xs font-semibold text-emerald-700 uppercase dark:bg-emerald-900/30 dark:text-emerald-400">
{promoBadge}
</div>
)}
</div>
<div className="mt-4 flex items-baseline gap-2">
@ -135,7 +134,6 @@ export default function Welcome({
const { appUrl, subscriptionsEnabled, pricing } =
usePage<SharedData>().props;
const planEntries = Object.entries(pricing.plans);
const emailInputRef = useRef<HTMLInputElement>(null);
const visitTrackedRef = useRef(false);
useEffect(() => {
@ -240,7 +238,7 @@ export default function Welcome({
<span className="inline-flex items-center gap-2 rounded-full border border-[#e3e3e0] px-2.5 py-1 text-[0.8rem] font-medium dark:border-[#3E3E3A]">
<LockIcon className="size-3.5 opacity-75" />
<span className="text-[#706f6c] dark:text-[#A1A09A]">
Military Grade Encryption
E2E Encryption
</span>
</span>
<h1 className="font-heading max-w-[840px] bg-gradient-to-r from-[#1b1b18] to-[#1b1b18] bg-clip-text text-4xl leading-tight font-semibold text-transparent drop-shadow-2xl sm:text-5xl sm:leading-tight lg:text-6xl lg:leading-tight dark:from-[#EDEDEC] dark:to-[#A1A09A]">
@ -254,46 +252,15 @@ export default function Welcome({
while keeping your information completely
secure.
</p>
<div className="flex w-full max-w-lg flex-col gap-4">
<Form
{...store.form()}
className="flex flex-col justify-center gap-2 sm:flex-row sm:items-center"
>
{({ processing, errors }) => (
<>
<Input
ref={emailInputRef}
type="email"
name="email"
placeholder="Enter your email"
required
className="h-12 border-[#e3e3e0] bg-[#FDFDFC] dark:border-[#3E3E3A] dark:bg-[#0a0a0a]"
autoComplete="email"
/>
<Button
type="submit"
disabled={processing}
className="text-shadow duration h-12 cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 px-6 text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md disabled:cursor-default dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md"
>
{processing
? 'Submitting...'
: 'Get Early Access'}
</Button>
{errors.email && (
<div className="w-full">
<InputError
message={
errors.email
}
/>
</div>
)}
</>
)}
</Form>
<div className="flex w-full max-w-xs flex-col gap-4">
<Link href="/register">
<Button className="text-shadow duration h-14 w-full cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md">
Get Started
</Button>
</Link>
<p className="text-xs text-[#706f6c] dark:text-[#A1A09A]">
Your data is yours alone. Join our
waitlist for early access.
Your data is yours alone. Sign up to get
started.
</p>
</div>
</div>
@ -365,6 +332,170 @@ export default function Welcome({
</div>
</section>
<section className="px-4 py-12 sm:py-24 md:py-32">
<div className="mx-auto flex max-w-7xl flex-col items-center gap-8 sm:gap-12">
<div className="flex flex-col items-center gap-4 text-center">
<h2 className="max-w-[720px] text-3xl leading-tight font-semibold text-balance sm:text-5xl sm:leading-tight">
How End-to-End Encryption Works
</h2>
<p className="text-md max-w-[640px] font-medium text-[#706f6c] sm:text-xl dark:text-[#A1A09A]">
Your financial data is encrypted on your
device before it ever reaches our servers.
</p>
</div>
<div className="grid w-full gap-8 sm:grid-cols-3">
<div className="flex flex-col items-center gap-4 rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-8 text-center dark:border-[#3E3E3A] dark:bg-[#161615]">
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-500/10">
<KeyIcon className="size-8 text-emerald-600 dark:text-emerald-400" />
</div>
<h3 className="text-xl font-semibold">
Your Private Key
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
A unique encryption key is generated on
your device. Only you have access to
itwe never see or store it.
</p>
</div>
<div className="flex flex-col items-center gap-4 rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-8 text-center dark:border-[#3E3E3A] dark:bg-[#161615]">
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-500/10">
<LockIcon className="size-8 text-emerald-600 dark:text-emerald-400" />
</div>
<h3 className="text-xl font-semibold">
Client-Side Encryption
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
Your transactions, accounts, and budgets
are encrypted on your device before
syncing to the cloud.
</p>
</div>
<div className="flex flex-col items-center gap-4 rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-8 text-center dark:border-[#3E3E3A] dark:bg-[#161615]">
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-500/10">
<ShieldCheckIcon className="size-8 text-emerald-600 dark:text-emerald-400" />
</div>
<h3 className="text-xl font-semibold">
Zero-Knowledge Architecture
</h3>
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
We store encrypted data we can't read.
Even if our servers were compromised,
your data stays secure.
</p>
</div>
</div>
</div>
</section>
<section className="px-4 py-12 sm:py-24 md:py-32">
<div className="mx-auto flex max-w-7xl flex-col items-center gap-8 sm:gap-12">
<div className="flex flex-col items-center gap-4 text-center">
<h2 className="max-w-[720px] text-3xl leading-tight font-semibold sm:text-5xl sm:leading-tight">
Privacy by Design
</h2>
<p className="text-md max-w-[640px] font-medium text-[#706f6c] sm:text-xl dark:text-[#A1A09A]">
No AI. No bank connections. Your privacy is
our priority.
</p>
</div>
<div className="grid w-full gap-8 sm:grid-cols-2">
<div className="flex flex-col gap-6 rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-8 dark:border-[#3E3E3A] dark:bg-[#161615]">
<div className="flex items-center gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-red-500/10">
<BrainIcon className="size-6 text-red-600 dark:text-red-400" />
</div>
<h3 className="text-2xl font-semibold">
No AI Snooping
</h3>
</div>
<p className="text-[#706f6c] dark:text-[#A1A09A]">
AI can't help you with your transactions
because they're end-to-end encrypted.
This is intentionalwe believe your
financial data should never be fed into
AI systems that you don't control.
</p>
</div>
<div className="flex flex-col gap-6 rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-8 dark:border-[#3E3E3A] dark:bg-[#161615]">
<div className="flex items-center gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-red-500/10">
<Building2Icon className="size-6 text-red-600 dark:text-red-400" />
</div>
<h3 className="text-2xl font-semibold">
No Bank Access Required
</h3>
</div>
<p className="text-[#706f6c] dark:text-[#A1A09A]">
We don't need direct access to your bank
accounts. No sharing credentials, no
third-party integrations, no security
risks. You stay in complete control of
your financial data.
</p>
</div>
</div>
</div>
</section>
<section className="px-4 py-12 sm:py-24 md:py-32">
<div className="mx-auto flex max-w-7xl flex-col items-center gap-8 sm:gap-12">
<div className="flex flex-col items-center gap-4 text-center">
<h2 className="max-w-[720px] text-3xl leading-tight font-semibold text-balance sm:text-5xl sm:leading-tight">
Import Your Transactions in Seconds
</h2>
<p className="text-md max-w-[640px] font-medium text-[#706f6c] sm:text-xl dark:text-[#A1A09A]">
Get started quickly with your existing
financial data.
</p>
</div>
<div className="flex w-full max-w-4xl flex-col items-center gap-8 rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-8 sm:p-12 dark:border-[#3E3E3A] dark:bg-[#161615]">
<div className="flex size-20 items-center justify-center rounded-full bg-emerald-500/10">
<FileUpIcon className="size-10 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="flex flex-col gap-6 text-center">
<h3 className="text-2xl font-semibold">
Lightning-Fast CSV/XLS Import
</h3>
<p className="text-lg text-[#706f6c] dark:text-[#A1A09A]">
Import a year's worth of transactions in
under 10 seconds. Simply export a CSV or
XLS file from your bank and drag it into
Whisper Money. All data is encrypted
locally before upload.
</p>
</div>
<div className="grid w-full gap-4 sm:grid-cols-3">
<div className="flex items-center gap-3 rounded-lg border border-[#e3e3e0] bg-background p-4 dark:border-[#3E3E3A]">
<CheckIcon className="size-5 shrink-0 text-emerald-500" />
<span className="text-sm font-medium">
Export from any bank
</span>
</div>
<div className="flex items-center gap-3 rounded-lg border border-[#e3e3e0] bg-background p-4 dark:border-[#3E3E3A]">
<CheckIcon className="size-5 shrink-0 text-emerald-500" />
<span className="text-sm font-medium">
Encrypted on your device
</span>
</div>
<div className="flex items-center gap-3 rounded-lg border border-[#e3e3e0] bg-background p-4 dark:border-[#3E3E3A]">
<CheckIcon className="size-5 shrink-0 text-emerald-500" />
<span className="text-sm font-medium">
Import in seconds
</span>
</div>
</div>
</div>
</div>
</section>
<section className="px-4 py-12 sm:py-24 md:py-32 dark:border-[#3E3E3A]">
<div className="mx-auto flex max-w-7xl flex-col items-center gap-6 sm:gap-20">
<h2 className="max-w-[560px] text-center text-3xl leading-tight font-semibold sm:text-5xl sm:leading-tight">
@ -519,7 +650,7 @@ export default function Welcome({
</div>
{pricing.promo.enabled && (
<p className="-mt-6 text-center text-sm text-[#706f6c] dark:text-[#A1A09A]">
<p className="text-center text-sm text-[#706f6c] dark:text-[#A1A09A]">
🎉 Get a founder discount {' '}
<TooltipProvider>
<Tooltip>

View File

@ -39,6 +39,7 @@ export interface NavDivider {
export interface SharedData {
name: string;
appUrl: string;
version: string;
quote: { message: string; author: string };
auth: Auth;
subscriptionsEnabled: boolean;

View File

@ -0,0 +1,25 @@
<x-mail::message>
# How's it going, {{ $userName }}?
Hi! It's Victor here, the founder of Whisper Money. You've been using the app for a few days now, and I'd love to hear how it's working for you.
## A Few Quick Questions
- Is the app meeting your expectations?
- Is there anything confusing or frustrating?
- What features would make Whisper Money more useful for you?
As a solo founder, your feedback directly shapes what I build next. I personally read every response and take your suggestions seriously. When you subscribe, you're not just supporting some big corporation - you're helping me continue building something I'm passionate about.
## Help Me Improve
Whether it's a bug you've found, a feature you'd love to see, or just general thoughts - I want to hear it all. This is my project, and I care deeply about making it work well for you.
Just reply to this email with your feedback. No surveys, no forms, just a direct conversation with me.
Thanks for being part of Whisper Money. It really means a lot.
Best,<br>
Víctor F,<br>
Founder of Whisper Money
</x-mail::message>

View File

@ -0,0 +1,32 @@
<x-mail::message>
# Need help importing your transactions, {{ $userName }}?
Hi! It's Victor, the founder of Whisper Money. I noticed you've completed your setup but haven't imported any transactions yet. Let me help you get started!
## How to Import Your Transactions
**Step 1: Export from your bank**
Log into your bank's website and look for "Export" or "Download transactions". Choose CSV format if available.
**Step 2: Upload to Whisper Money**
Go to your dashboard and click "Import Transactions". Select your CSV file and I'll map the columns automatically.
**Step 3: Review and confirm**
Check that everything looks correct and click "Import". Your transactions will be encrypted and stored securely.
## Prefer to Start Fresh?
You can also manually add transactions and account balances. Some users prefer to start tracking from today rather than importing history - that's totally fine! Do whatever works best for you.
<x-mail::button :url="config('app.url') . '/dashboard'">
Go to Dashboard
</x-mail::button>
If you're having trouble with the import or need help with your specific bank's format, just reply to this email. I personally handle support and I'm happy to help you figure it out.
Thanks for giving Whisper Money a try!
Best,<br>
Víctor F,<br>
Founder of Whisper Money
</x-mail::message>

View File

@ -0,0 +1,28 @@
<x-mail::message>
# Hey {{ $userName }}, is everything okay?
Hi! It's Victor, the founder of Whisper Money. I noticed you signed up but haven't completed your setup yet. I wanted to check in personally and see if something went wrong or if you need any help.
I know setting up a new app can be overwhelming, and I want to make sure you have everything you need to get started.
## Common Questions
**Is the encryption confusing?**
No worries! Your encryption key is automatically generated and stored securely on your device. You don't need to remember anything - I've made it as simple as possible.
**Not sure how to import transactions?**
I support CSV imports from most banks. Just export your transactions and upload them - it takes less than a minute. If your bank format is different, just let me know and I can help.
**Something not working?**
Just reply to this email and let me know what's happening. I personally read every response and will help you get started. This is my project, so I care about making sure it works for you.
<x-mail::button :url="config('app.url') . '/onboarding'">
Continue Setup
</x-mail::button>
Looking forward to hearing from you! And if you decide Whisper Money isn't for you, that's totally fine - but I'd love to know why so I can improve.
Best,<br>
Víctor F,<br>
Founder of Whisper Money
</x-mail::message>

View File

@ -0,0 +1,32 @@
<x-mail::message>
# Welcome aboard, {{ $userName }}!
Hi! It's Victor, the founder of Whisper Money. I see you've already started importing your transactions - that's awesome! You're well on your way to taking control of your finances while keeping your data private.
## A Special Offer for You
As one of our early users, I want to offer you a special founder's discount. When you subscribe, you're not just getting a great app - you're directly supporting me as I continue building Whisper Money. Every subscription helps me keep the lights on and build features you actually want.
<x-mail::panel>
Use code **{{ $promoCode }}** to get your first month for just **$1**
</x-mail::panel>
This gives you full access to all Whisper Money features:
- Unlimited transaction imports
- Automated categorization rules
- Multiple account tracking
- End-to-end encrypted storage
<x-mail::button :url="config('app.url') . '/subscribe'">
Claim Your Discount
</x-mail::button>
This code won't last forever, but more importantly, your support means the world to me. As a solo founder, every subscriber helps me continue building something I'm passionate about.
Thanks for being part of this journey with me!
Best,<br>
Víctor F,<br>
Founder of Whisper Money
</x-mail::message>

View File

@ -0,0 +1,31 @@
<x-mail::message>
# Welcome to Whisper Money, {{ $userName }}!
Hi! I'm Victor, the founder of Whisper Money. I wanted to personally welcome you and thank you for joining us.
When I started building Whisper Money, I was frustrated with finance apps that wanted to mine my data or use AI to analyze my spending. I just wanted a simple, private way to track my finances - and I figured others might too.
## Your Data is Truly Private
I built Whisper Money with **end-to-end encryption** because I believe your financial data should be yours alone:
- Your financial data is encrypted with a key that only you control
- I cannot read your transactions, balances, or any personal information (and I don't want to!)
- Your encryption key never leaves your device
## No AI, No Data Mining
I don't use AI to analyze your spending or sell insights about your habits. Your financial data stays between you and your spreadsheet-loving self.
What's more, even if I wanted to use it, I couldn't, since you're the only one who has the key to read the data.
<div style="height: 40px"></div>
If you have any questions or run into any issues, **just reply to this email**. I personally read every message and I'm here to help!
Thanks for giving Whisper Money a try. It means a lot to me.
Best,<br>
Víctor F,<br>
Founder of Whisper Money
</x-mail::message>

View File

@ -0,0 +1,47 @@
<x-mail::message>
# Your early access to Whisper Money is ready
Hey there!
It's Victor, the founder of Whisper Money. You signed up a while back to hear about our privacy-first personal finance app, and I'm excited to tell you - we're live!
## What is Whisper Money?
I built Whisper Money because I was tired of giving my financial data to big companies who use it for who knows what. Whisper Money keeps your data **end-to-end encrypted** - only you can see your transactions, categories, and insights. Not me, not anyone.
It's personal finance, but actually private.
## Special Founder's Offer - Just for You
As someone who believed in us early, I want to offer you something special:
<x-mail::panel>
Use code **FOUNDER** to get **$8 off** - your first month for just **$1**
</x-mail::panel>
This gets you full access to everything:
- Unlimited transaction imports
- Automated categorization rules
- Multiple account tracking
- End-to-end encrypted storage
- Mobile app (iOS & Android)
<x-mail::button :url="config('app.url') . '/register'">
Get Started for $1
</x-mail::button>
## Built by one person, for real people
I'm a solo founder building this in public. Every subscription helps me keep the lights on and build features you actually want. No investors, no board meetings, just me trying to build something useful and private.
**Have feedback? Questions? Issues?** Just hit reply to this email. I read and respond to every message personally.
Thanks for being interested in what I'm building!
Best,<br>
Víctor<br>
Founder, Whisper Money
P.S. The FOUNDER code gives you $8 off, so your first month is just $1. After that, it's $9/month. Cancel anytime, no questions asked.
</x-mail::message>

View File

@ -0,0 +1,24 @@
@props([
'url',
'color' => 'primary',
'align' => 'center',
])
<table class="action" align="{{ $align }}" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="{{ $align }}">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="{{ $align }}">
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<a href="{{ $url }}" class="button button-{{ $color }}" target="_blank" rel="noopener">{!! $slot !!}</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,11 @@
<tr>
<td>
<table class="footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>
</td>
</tr>

View File

@ -0,0 +1,8 @@
@props(['url'])
<tr>
<td class="header">
<a href="{{ $url }}" style="display: inline-block;">
<img src="{{ $url }}/images/whisper_money_e.png" width="75" height="75" title="Whisper Money" alt="Whisper Money" />
</a>
</td>
</tr>

View File

@ -0,0 +1,58 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>{{ config('app.name') }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<style>
@media only screen and (max-width: 600px) {
.inner-body {
width: 100% !important;
}
.footer {
width: 100% !important;
}
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
}
}
</style>
{!! $head ?? '' !!}
</head>
<body>
<table class="wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
{!! $header ?? '' !!}
<!-- Email Body -->
<tr>
<td class="body" width="100%" cellpadding="0" cellspacing="0" style="border: hidden !important;">
<table class="inner-body" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<!-- Body content -->
<tr>
<td class="content-cell">
{!! Illuminate\Mail\Markdown::parse($slot) !!}
{!! $subcopy ?? '' !!}
</td>
</tr>
</table>
</td>
</tr>
{!! $footer ?? '' !!}
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,27 @@
<x-mail::layout>
{{-- Header --}}
<x-slot:header>
<x-mail::header :url="config('app.url')">
{{ config('app.name') }}
</x-mail::header>
</x-slot:header>
{{-- Body --}}
{!! $slot !!}
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{!! $subcopy !!}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}}
<x-slot:footer>
<x-mail::footer>
© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}
</x-mail::footer>
</x-slot:footer>
</x-mail::layout>

View File

@ -0,0 +1,14 @@
<table class="panel" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="panel-content">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="panel-item">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,7 @@
<table class="subcopy" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>

View File

@ -0,0 +1,3 @@
<div class="table">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</div>

View File

@ -0,0 +1,300 @@
/* Base */
body,
body *:not(html):not(style):not(br):not(tr):not(code) {
box-sizing: border-box;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
position: relative;
}
body {
-webkit-text-size-adjust: none;
background-color: #ffffff;
color: #52525b;
height: 100%;
line-height: 1.4;
margin: 0;
padding: 0;
width: 100% !important;
}
p,
ul,
ol,
blockquote {
line-height: 1.4;
text-align: left;
}
a {
color: #18181b;
}
a img {
border: none;
}
/* Typography */
h1 {
color: #18181b;
font-size: 18px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
h2 {
font-size: 16px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
h3 {
font-size: 14px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
p {
font-size: 16px;
line-height: 1.5em;
margin-top: 0;
text-align: left;
}
p.sub {
font-size: 12px;
}
img {
max-width: 100%;
}
/* Layout */
.wrapper {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: #fafafa;
margin: 0;
padding: 0;
width: 100%;
}
.content {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 0;
padding: 0;
width: 100%;
}
/* Header */
.header {
padding: 25px 0;
text-align: center;
}
.header a {
color: #18181b;
font-size: 19px;
font-weight: bold;
text-decoration: none;
}
/* Logo */
.logo {
height: 75px;
margin-top: 15px;
margin-bottom: 10px;
max-height: 75px;
width: 75px;
}
/* Body */
.body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: #fafafa;
border-bottom: 1px solid #fafafa;
border-top: 1px solid #fafafa;
margin: 0;
padding: 0;
width: 100%;
}
.inner-body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
background-color: #ffffff;
border-color: #e4e4e7;
border-radius: 4px;
border-width: 1px;
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px -1px rgba(0, 0, 0, 0.1);
margin: 0 auto;
padding: 0;
width: 570px;
}
.inner-body a {
word-break: break-all;
}
/* Subcopy */
.subcopy {
border-top: 1px solid #e4e4e7;
margin-top: 25px;
padding-top: 25px;
}
.subcopy p {
font-size: 14px;
}
/* Footer */
.footer {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
margin: 0 auto;
padding: 0;
text-align: center;
width: 570px;
}
.footer p {
color: #a1a1aa;
font-size: 12px;
text-align: center;
}
.footer a {
color: #a1a1aa;
text-decoration: underline;
}
/* Tables */
.table table {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 30px auto;
width: 100%;
}
.table th {
border-bottom: 1px solid #e4e4e7;
margin: 0;
padding-bottom: 8px;
}
.table td {
color: #52525b;
font-size: 15px;
line-height: 18px;
margin: 0;
padding: 10px 0;
}
.content-cell {
max-width: 100vw;
padding: 32px;
}
/* Buttons */
.action {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 30px auto;
padding: 0;
text-align: center;
width: 100%;
float: unset;
}
.button {
-webkit-text-size-adjust: none;
border-radius: 4px;
color: #fff;
display: inline-block;
overflow: hidden;
text-decoration: none;
}
.button-blue,
.button-primary {
background-color: #18181b;
border-bottom: 8px solid #18181b;
border-left: 18px solid #18181b;
border-right: 18px solid #18181b;
border-top: 8px solid #18181b;
}
.button-green,
.button-success {
background-color: #16a34a;
border-bottom: 8px solid #16a34a;
border-left: 18px solid #16a34a;
border-right: 18px solid #16a34a;
border-top: 8px solid #16a34a;
}
.button-red,
.button-error {
background-color: #dc2626;
border-bottom: 8px solid #dc2626;
border-left: 18px solid #dc2626;
border-right: 18px solid #dc2626;
border-top: 8px solid #dc2626;
}
/* Panels */
.panel {
border-left: #18181b solid 4px;
margin: 21px 0;
}
.panel-content {
background-color: #fafafa;
color: #52525b;
padding: 16px;
}
.panel-content p {
color: #52525b;
}
.panel-item {
padding: 0;
}
.panel-item p:last-of-type {
margin-bottom: 0;
padding-bottom: 0;
}
/* Utilities */
.break-all {
word-break: break-all;
}

View File

@ -0,0 +1 @@
{{ $slot }}: {{ $url }}

View File

@ -0,0 +1 @@
{{ $slot }}

View File

@ -0,0 +1 @@
{{ $slot }}: {{ $url }}

View File

@ -0,0 +1,9 @@
{!! strip_tags($header ?? '') !!}
{!! strip_tags($slot) !!}
@isset($subcopy)
{!! strip_tags($subcopy) !!}
@endisset
{!! strip_tags($footer ?? '') !!}

View File

@ -0,0 +1,27 @@
<x-mail::layout>
{{-- Header --}}
<x-slot:header>
<x-mail::header :url="config('app.url')">
{{ config('app.name') }}
</x-mail::header>
</x-slot:header>
{{-- Body --}}
{{ $slot }}
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{{ $subcopy }}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}}
<x-slot:footer>
<x-mail::footer>
© {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.')
</x-mail::footer>
</x-slot:footer>
</x-mail::layout>

View File

@ -0,0 +1 @@
{{ $slot }}

View File

@ -0,0 +1 @@
{{ $slot }}

View File

@ -0,0 +1 @@
{{ $slot }}

View File

@ -1,8 +1,5 @@
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::command('horizon:snapshot')->everyFiveMinutes();

View File

@ -1,5 +1,7 @@
<?php
use Illuminate\Support\Facades\Queue;
test('registration screen can be rendered', function () {
$response = $this->get(route('register'));
@ -7,6 +9,8 @@ test('registration screen can be rendered', function () {
});
test('new users can register', function () {
Queue::fake();
$response = $this->post(route('register.store'), [
'name' => 'Test User',
'email' => 'test@example.com',

View File

@ -0,0 +1,142 @@
<?php
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\AutomationRule;
use App\Models\Bank;
use App\Models\Category;
use App\Models\EncryptedMessage;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Cashier\Subscription;
uses(RefreshDatabase::class);
test('deletes user and all associated data when confirmed', function () {
$user = User::factory()->onboarded()->create([
'email' => 'test@example.com',
'name' => 'Test User',
]);
// Create associated data
EncryptedMessage::query()->create([
'user_id' => $user->id,
'encrypted_content' => 'test-content',
'iv' => 'test-iv',
]);
Transaction::factory()->count(3)->create(['user_id' => $user->id]);
$account = Account::factory()->create(['user_id' => $user->id]);
AccountBalance::factory()->count(2)->create(['account_id' => $account->id]);
Account::factory()->create(['user_id' => $user->id]);
Category::factory()->count(2)->create(['user_id' => $user->id]);
AutomationRule::factory()->count(1)->create(['user_id' => $user->id]);
Label::factory()->count(2)->create(['user_id' => $user->id]);
UserMailLog::factory()->count(1)->create(['user_id' => $user->id]);
Bank::factory()->count(2)->create(['user_id' => $user->id]);
// Confirm deletion
$this->artisan('user:delete', ['email' => 'test@example.com'])
->expectsConfirmation("Are you sure you want to delete user 'Test User' (test@example.com) and all their data?", 'yes')
->expectsOutput("User 'test@example.com' and all associated data have been deleted successfully.")
->assertSuccessful();
// Verify user is deleted
expect(User::query()->where('email', 'test@example.com')->exists())->toBeFalse();
// Verify all associated data is deleted
expect(EncryptedMessage::query()->where('user_id', $user->id)->exists())->toBeFalse();
expect(Transaction::query()->where('user_id', $user->id)->exists())->toBeFalse();
expect(Account::query()->where('user_id', $user->id)->exists())->toBeFalse();
expect(AccountBalance::query()->where('account_id', $account->id)->exists())->toBeFalse();
expect(Category::query()->where('user_id', $user->id)->exists())->toBeFalse();
expect(AutomationRule::query()->where('user_id', $user->id)->exists())->toBeFalse();
expect(Label::query()->where('user_id', $user->id)->exists())->toBeFalse();
expect(UserMailLog::query()->where('user_id', $user->id)->exists())->toBeFalse();
expect(Bank::query()->where('user_id', $user->id)->exists())->toBeFalse();
});
test('shows error when user not found', function () {
$this->artisan('user:delete', ['email' => 'nonexistent@example.com'])
->expectsOutput("User with email 'nonexistent@example.com' not found.")
->assertFailed();
});
test('cancels deletion when not confirmed', function () {
$user = User::factory()->onboarded()->create([
'email' => 'test@example.com',
'name' => 'Test User',
]);
$this->artisan('user:delete', ['email' => 'test@example.com'])
->expectsConfirmation("Are you sure you want to delete user 'Test User' (test@example.com) and all their data?", 'no')
->expectsOutput('Deletion cancelled.')
->assertSuccessful();
// Verify user still exists
expect(User::query()->where('email', 'test@example.com')->exists())->toBeTrue();
});
test('deletes user without associated data', function () {
$user = User::factory()->onboarded()->create([
'email' => 'test@example.com',
'name' => 'Test User',
]);
$this->artisan('user:delete', ['email' => 'test@example.com'])
->expectsConfirmation("Are you sure you want to delete user 'Test User' (test@example.com) and all their data?", 'yes')
->expectsOutput("User 'test@example.com' and all associated data have been deleted successfully.")
->assertSuccessful();
expect(User::query()->where('email', 'test@example.com')->exists())->toBeFalse();
});
test('does not delete other users data', function () {
$userToDelete = User::factory()->onboarded()->create([
'email' => 'delete@example.com',
]);
$otherUser = User::factory()->onboarded()->create([
'email' => 'keep@example.com',
]);
// Create data for both users
Account::factory()->create(['user_id' => $userToDelete->id]);
Account::factory()->create(['user_id' => $otherUser->id]);
$this->artisan('user:delete', ['email' => 'delete@example.com'])
->expectsConfirmation("Are you sure you want to delete user '{$userToDelete->name}' (delete@example.com) and all their data?", 'yes')
->assertSuccessful();
// Verify only the target user is deleted
expect(User::query()->where('email', 'delete@example.com')->exists())->toBeFalse();
expect(User::query()->where('email', 'keep@example.com')->exists())->toBeTrue();
// Verify other user's data is intact
expect(Account::query()->where('user_id', $otherUser->id)->exists())->toBeTrue();
});
test('prevents deletion of user with active subscription', function () {
$user = User::factory()->onboarded()->create([
'email' => 'subscribed@example.com',
'name' => 'Subscribed User',
]);
// Create an active subscription
Subscription::query()->create([
'user_id' => $user->id,
'type' => 'default',
'stripe_id' => 'sub_test123',
'stripe_status' => 'active',
'stripe_price' => 'price_test123',
'quantity' => 1,
]);
$this->artisan('user:delete', ['email' => 'subscribed@example.com'])
->expectsOutput('Cannot delete user with an active subscription. Please cancel the subscription first.')
->assertFailed();
// Verify user still exists
expect(User::query()->where('email', 'subscribed@example.com')->exists())->toBeTrue();
});

View File

@ -0,0 +1,53 @@
<?php
use App\Enums\DripEmailType;
use App\Jobs\Drip\SendFeedbackEmailJob;
use App\Mail\Drip\FeedbackEmail;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
Mail::fake();
config(['subscriptions.enabled' => true]);
});
test('feedback email is sent to non-subscribed users', function () {
$user = User::factory()->create();
SendFeedbackEmailJob::dispatchSync($user);
Mail::assertQueued(FeedbackEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
$this->assertDatabaseHas('user_mail_logs', [
'user_id' => $user->id,
'email_type' => DripEmailType::Feedback->value,
]);
});
test('feedback email is not sent to subscribed users', function () {
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_test123',
'stripe_status' => 'active',
'stripe_price' => 'price_test123',
]);
SendFeedbackEmailJob::dispatchSync($user);
Mail::assertNotQueued(FeedbackEmail::class);
});
test('feedback email is not sent if already received', function () {
$user = User::factory()->create();
UserMailLog::factory()->for($user)->feedback()->create();
SendFeedbackEmailJob::dispatchSync($user);
Mail::assertNotQueued(FeedbackEmail::class);
});

View File

@ -0,0 +1,71 @@
<?php
use App\Enums\DripEmailType;
use App\Jobs\Drip\SendImportHelpEmailJob;
use App\Mail\Drip\ImportHelpEmail;
use App\Models\Transaction;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
Mail::fake();
config(['subscriptions.enabled' => true]);
});
test('import help email is sent to onboarded users without transactions who are not subscribed', function () {
$user = User::factory()->onboarded()->create();
SendImportHelpEmailJob::dispatchSync($user);
Mail::assertQueued(ImportHelpEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
$this->assertDatabaseHas('user_mail_logs', [
'user_id' => $user->id,
'email_type' => DripEmailType::ImportHelp->value,
]);
});
test('import help email is not sent to non-onboarded users', function () {
$user = User::factory()->notOnboarded()->create();
SendImportHelpEmailJob::dispatchSync($user);
Mail::assertNotQueued(ImportHelpEmail::class);
});
test('import help email is not sent to users with transactions', function () {
$user = User::factory()->onboarded()->create();
Transaction::factory()->for($user)->create();
SendImportHelpEmailJob::dispatchSync($user);
Mail::assertNotQueued(ImportHelpEmail::class);
});
test('import help email is not sent to subscribed users', function () {
$user = User::factory()->onboarded()->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_test123',
'stripe_status' => 'active',
'stripe_price' => 'price_test123',
]);
SendImportHelpEmailJob::dispatchSync($user);
Mail::assertNotQueued(ImportHelpEmail::class);
});
test('import help email is not sent if already received', function () {
$user = User::factory()->onboarded()->create();
UserMailLog::factory()->for($user)->importHelp()->create();
SendImportHelpEmailJob::dispatchSync($user);
Mail::assertNotQueued(ImportHelpEmail::class);
});

View File

@ -0,0 +1,50 @@
<?php
use App\Enums\DripEmailType;
use App\Jobs\Drip\SendOnboardingReminderEmailJob;
use App\Mail\Drip\OnboardingReminderEmail;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
Mail::fake();
});
test('onboarding reminder email is sent to non-onboarded users', function () {
$user = User::factory()->notOnboarded()->create();
SendOnboardingReminderEmailJob::dispatchSync($user);
Mail::assertQueued(OnboardingReminderEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
$this->assertDatabaseHas('user_mail_logs', [
'user_id' => $user->id,
'email_type' => DripEmailType::OnboardingReminder->value,
]);
});
test('onboarding reminder email is not sent to onboarded users', function () {
$user = User::factory()->onboarded()->create();
SendOnboardingReminderEmailJob::dispatchSync($user);
Mail::assertNotQueued(OnboardingReminderEmail::class);
$this->assertDatabaseMissing('user_mail_logs', [
'user_id' => $user->id,
'email_type' => DripEmailType::OnboardingReminder->value,
]);
});
test('onboarding reminder email is not sent if already received', function () {
$user = User::factory()->notOnboarded()->create();
UserMailLog::factory()->for($user)->onboardingReminder()->create();
SendOnboardingReminderEmailJob::dispatchSync($user);
Mail::assertNotQueued(OnboardingReminderEmail::class);
});

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