Commit Graph

58 Commits

Author SHA1 Message Date
Víctor Falcón 22043ced29
fix(budgets): remove Custom period type to fix duplicate-key crash (#355)
## Why merge this

Production bug
**[PHP-LARAVEL-1B](https://whisper-money.sentry.io/issues/PHP-LARAVEL-1B)**
— `/budgets/{budget}` throws a 500 (`UniqueConstraintViolationException`
on `budget_periods_budget_id_start_date_unique`) for the affected user.
Page is unusable for them until fixed.

## Root cause

The Custom period type let users create budgets with arbitrary
`period_duration`. Combined with `calculatePeriodDates()`'s day-of-month
snap (`startDate->day(period_start_day)`), short durations produced
periods where `end_date == start_date == period_start_day`. On the next
visit after that day, regeneration computed a new period whose start
snapped backward to the same day → unique key collision.

Concrete trace from the Sentry event:

- Budget: `Seguro de Salud`, `period_type=custom, period_duration=1,
period_start_day=1`, created `2026-05-05`.
- Initial period created at budget creation: `start=2026-05-01,
end=2026-05-01` (already 4 days stale because Custom snapped
`now()=05-05` back to day 1, then `addDays(1).subDay()` produced same
date).
- Today `05-05` → `getCurrentPeriod()` returns null → `generatePeriod()`
snaps back to `05-01` again → `INSERT (budget_id, 05-01)` → 1062.

Verified in prod DB: this is the **only** budget in the entire database
with `period_type='custom'` and the only period row with `end_date <=
start_date`.

## Fix

Custom is the only period type that exposes this snap-back collision
(Monthly/Weekly/Biweekly all use a self-consistent advance). It also has
no defensible UX — every legitimate use case is covered by
Monthly/Weekly/Biweekly. So we remove it end-to-end:

- `BudgetPeriodType::Custom` enum case dropped.
- `BudgetPeriodService`: Custom branches removed from
`calculatePeriodDates()` and `calculateNextPeriodStartDate()`.
- `Budget` model: `period_duration` removed from `$fillable` and
`casts()`.
- `StoreBudgetRequest` / `UpdateBudgetRequest`: `period_duration` rule
removed.
- `BudgetController`: `period_duration` no longer passed in
store/update.
- Create + edit budget dialogs: Custom option and `period_duration`
input removed.
- `ResetDemoAccountCommand`: Custom switch case removed.
- `BudgetFactory`: `custom()` state and `period_duration` default
removed.
- `types/budget.ts`: `'custom'` removed from `BUDGET_PERIOD_TYPES` and
`Budget.period_duration` field dropped.
- Migration `2026_05_05_132023_convert_custom_budgets_to_monthly`:
rewrites any existing `custom` rows to `monthly` with
`period_duration=null, period_start_day=1` so the dropped enum case
can't crash on hydration.

Period generation logic for Monthly/Weekly/Biweekly is **unchanged**.

## Manual prod cleanup (separate, after merge)

The single buggy budget in prod will be deleted manually:

```sql
DELETE FROM budget_periods WHERE id = '019df7fd-6073-731b-9c08-f3723515292b';
DELETE FROM budgets WHERE id = '019df7fd-6071-7219-b190-ece22ccdb63f';
```

## Tests

`tests/Feature/BudgetPeriodServiceTest.php` covers Monthly + Weekly
advance and Monthly first-period generation. Existing
`BudgetPeriodDateTest` unaffected.

## Risk

- `period_duration` column kept in DB (nullable) to avoid data loss; no
migration needed.
- Migration ensures any environment with `period_type='custom'` rows is
converted before the enum case is removed, preventing hydration errors.
- No customers actively rely on Custom: prod query confirmed only the
single broken budget uses it, and that one is being deleted.

Fixes PHP-LARAVEL-1B
2026-05-05 16:07:16 +02:00
Víctor Falcón e387c038ca
perf(resend): default sync-leads to last 24h window (#354)
## Why

`resend:sync-leads` was syncing every verified lead on each run, taking
4+ minutes.

## Change

- New `--since=N` option (hours). Default `24`.
- `--since=0` syncs all leads (manual backfill).
- Scheduler unchanged: runs daily at 03:00 → 24h window covers it.

## Tests

- Default 24h window excludes older leads.
- `--since=0` syncs everything.
2026-05-05 09:57:25 +01:00
Víctor Falcón f800847591
feat(banking): back off scheduler when EnableBanking returns 429 (#352)
## Problem

Production logs show repeated EnableBanking 429s on the same
connections, every cron cycle:

```
[2026-05-04 18:00:55] EnableBanking API error status:429
  body: [HUB046] Allowed number of accesses exceeded for consent
[2026-05-04 21:47:12] EnableBanking API error status:429
  body: Daily PSU not present consultation limit has been exceeded
[2026-05-05 00:01:41] same connection, same error
[2026-05-05 06:01:41] same connection, same error
```

Root cause: `SyncBankingConnectionJob` returned early on 429s without
persisting any backoff state. The scheduler kept re-dispatching the same
connection on every run, hammering the provider and burning the daily
quota.

## Fix

Persist a per-connection backoff window so the scheduler stops
re-dispatching until the provider quota resets.

- New `rate_limited_until` column on `banking_connections`.
- On 429: derive the window from
  1. `Retry-After` header if present,
2. "Daily ..." message → next UTC midnight (matches PSU daily limit
semantics),
  3. default 1 hour (consent / generic).
- Job short-circuits with a `Skipped` sync log if the window is still
active.
- Successful sync clears the window.
- `SyncAllBankingConnectionsJob` + `SyncBankingConnections` command
filter out connections still inside their backoff.

## Tests

- Existing rate-limit test updated (now also asserts the backoff is
set).
- New: daily message → next UTC midnight.
- New: `Retry-After` header honoured (1800s).
- New: rate-limited connection skipped without calling provider.
- New: successful sync clears `rate_limited_until`.
- New: scheduler excludes connections whose backoff has not expired.

`php artisan test --compact
--filter="SyncBankingConnectionJobTest|SyncRetryAndLoggingTest|SyncBankingConnectionsCommandTest"`
→ 70 passed.
2026-05-05 09:39:32 +02:00
Víctor Falcón ab3d6e9fca
feat(leads): cohort-based launch invitations with per-user Stripe coupons (#333)
## Summary

Wires up the launch flow: each waitlist lead gets a per-cohort
invitation email, a personal single-use Stripe promo code matching their
reward, and a signed landing link that unlocks register/install.

## Cohorts (resolved at send-time, by queue rank ASC, ignoring
`position` null/0)

| Cohort | Rule | Reward | Stripe coupon |
|---|---|---|---|
| `founder` | ranks 1–10 | Free forever | `wm_founder_forever` (100%
off, forever) |
| `founder_referrer` | referred any current founder (overrides rank) |
Free forever | `wm_founder_forever` |
| `early_bird` | ranks 11–100 | 2 mo free monthly / 3 mo free yearly
first year | `wm_earlybird_monthly` (100% off, 2 mo, monthly only) +
`wm_earlybird_yearly` (25% off once, yearly only) |
| `waitlist` | ranks 101+ | same as early bird | same coupons |

## What's added

- **DB**: `cohort`, `promo_code_monthly`, `promo_code_yearly`,
`invitation_sent_at` on `user_leads`.
- **Services**: `LeadCohortResolver`, `LeadPromoCodeAllocator`.
- **Commands**:
- `php artisan stripe:ensure-launch-coupons` — idempotent Stripe coupon
setup (run once per env).
- `php artisan leads:send-invitations --limit=N [--cohort=…] [--dry-run]
[--force]` — wave-by-wave delivery, idempotent across runs
(`invitation_sent_at` gate). Lazily generates Stripe promo codes per
lead it touches.
- **Checkout**: `SubscriptionController::checkout` resolves the auth
user's `UserLead` by email and applies the matching promo code
(`monthly`/`yearly`) via Cashier's `withPromotionCode()`.
- **Invite link**:
`LandingAuthOverrideService::generateInvitationUrl($leadId, days: 30)` —
signed lead-bound URL that unlocks auth buttons (existing override
cookie) and stores `invited_lead_id` in session. The register view
prefills the email from the lead.
- **Emails**: 4 cohort markdown templates (`founder`,
`founder-referrer`, `early-bird`, `waitlist`) with per-cohort subject +
body. Locale set from `$lead->preferredLocale()`. 28 Spanish keys added.

## Tests (Pest)

- `LeadCohortResolverTest` — boundaries + founder-referrer override +
null/0 position skip.
- `SendUserLeadInvitationsTest` — batch ordering, idempotent across
runs, ignores null/0, persists cohort.
- `UserLeadInvitationTest` — per-cohort body content, signed lead-bound
signup URL, Spanish locale.
- `LandingAuthOverrideTest` — invitation URL unlocks + stores session
lead.

Full suite green (1262 passed).

## Launch checklist

1. Merge + deploy.
2. `php artisan stripe:ensure-launch-coupons` against staging Stripe →
verify in dashboard → run on prod.
3. Optional preflight: `php artisan leads:send-invitations --limit=10
--dry-run`.
4. Wave-by-wave: `php artisan leads:send-invitations --limit=50` per
day.
5. Confirm `HIDE_AUTH_BUTTONS=true` on prod so only signed invite links
unlock register/install.

## Notes / non-goals

- No `User → UserLead` foreign key — checkout matches by email per
request.
- Existing `FOUNDER` promo flow is untouched and still kicks in if a
user has no lead-specific code.
- `founder_referrer` cohort is empty in current prod data; logic is in
place for when founders start referring.
2026-04-30 15:10:28 +01:00
Víctor Falcón 13f741aaed
fix(real-estate): compound annual revaluation monthly (#337)
Previous formula divided annual % by 12 linearly, which over-applied
the configured rate when compounded over 12 months (e.g. 12% annual
grew balances ~12.68%/yr).

Use (1 + p/100)^(1/12) - 1 so 12 monthly applications equal the
configured annual percentage exactly. Works for negatives too.
2026-04-27 07:35:51 +01:00
Víctor Falcón 2604f1158c
Support soft-deleted users with reusable emails (#316)
## Summary
- soft-delete users by adding `deleted_at` to `users`
- rename deleted user emails with a timestamp prefix so original email
can be reused
- block email sends and banking follow-up work for deleted users while
preserving data

## Testing
- php artisan test --compact tests/Feature/DeleteUserCommandTest.php
tests/Feature/Settings/ProfileUpdateTest.php
tests/Feature/Auth/RegistrationTest.php
tests/Feature/Auth/AuthenticationTest.php
tests/Feature/Jobs/Drip/SendWelcomeEmailJobTest.php
tests/Feature/Console/SendUpdateEmailCommandTest.php
- vendor/bin/pint --dirty --format agent
2026-04-22 11:41:41 +01:00
Víctor Falcón 240fcf1703
feat(landing): add signed auth links (#312)
## Summary
- add signed landing links that unlock auth buttons while
HIDE_AUTH_BUTTONS is enabled
- persist the unlock in a secure cookie so desktop and installed PWA
users can still sign up
- add artisan command to generate signed landing auth links and test
coverage for the flow

## Testing
- php artisan test --compact tests/Feature/LandingAuthOverrideTest.php
tests/Feature/Console/GenerateLandingAuthLinkCommandTest.php
tests/Feature/Auth/RegistrationTest.php
- php artisan test --compact tests/Feature/WelcomeBanksOrderingTest.php
tests/Feature/SubscriptionTest.php
- vendor/bin/pint --dirty --format agent
2026-04-21 08:28:59 +01:00
Víctor Falcón 69665c3c58
feat(stripe): add promo code generator (#311)
## Summary
- add artisan command to generate N Stripe promotion codes
- default coupon to 0E5fAsXG and enforce single redemption
- add feature test coverage for success and validation

## Testing
- php artisan test --compact
tests/Feature/GenerateStripePromotionCodesCommandTest.php
- vendor/bin/pint --dirty --format agent
2026-04-20 18:15:28 +01:00
Víctor Falcón 3500eaa469
refactor(real-estate): remove Pennant gating (#308)
## Summary
- remove the `real-estate` Pennant flag definition, request gating,
local auto-activation middleware, and frontend shared flag plumbing
- make real-estate account creation and onboarding availability the
default path for all users
- update feature and browser tests to cover default availability instead
of flag enable/disable behavior

## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/RealEstateTest.php
- php artisan test --compact
tests/Feature/RealEstateAvailabilityTest.php
- php artisan test --compact tests/Browser/RealEstateAccountTest.php
- php artisan test --compact tests/Browser/OnboardingFlowTest.php
- php artisan test --compact --filter=\"can create a real estate account
linked to an existing loan\" tests/Browser/BankAccountsTest.php

## Notes
- `npm run build` completed the client build, but the Sentry sourcemap
upload step failed with an SSL/TLS error from the configured plugin
- deploy follow-up: run `php artisan pennant:purge real-estate` to
remove stale stored flag values
2026-04-20 13:31:49 +01:00
Víctor Falcón cfa54a2d9d
fix(demo-reset): use renamed 'ING Direct' bank (#301)
## Summary
- `demo:reset` was looking up the `ING` bank, but it was renamed to `ING
Direct` in production.
- The miss fell through to `Bank::factory()->create(...)`, which fails
in prod because Faker's `fake()` helper isn't available (dev-only
autoload).

## Fix
Update the name lookup in `ResetDemoAccountCommand` to `ING Direct`.

## Sentry
- Fixes PHP-LARAVEL-7 (root cause)
- Fixes PHP-LARAVEL-4 (scheduler wrapper)
2026-04-17 09:43:15 +01:00
Víctor Falcón 244344e953
feat(open-banking): remove feature flag gating (#297)
## Summary
- remove the Pennant-based `open-banking` flag and middleware gating so
open banking is always available for authenticated users
- simplify landing, onboarding, settings, and subscription flows to use
always-on open-banking behavior and remove stale frontend/shared flag
plumbing
- update open-banking tests and purge stored `open-banking` Pennant rows

## Testing
- `php artisan test --compact
tests/Feature/OpenBanking/InstitutionControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/BinanceControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/IndexaCapitalControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/BitpandaControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/AuthorizationControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/ConnectionControllerTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/AccountMappingTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/OpenBankingFeatureFlagTest.php`
- `php artisan test --compact
tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php`
- `php artisan test --compact tests/Feature/SubscriptionTest.php`
- `php artisan test --compact
tests/Feature/WelcomeBanksOrderingTest.php`
- `vendor/bin/pint --dirty --format agent`

## Notes
- `php artisan pennant:purge open-banking` was run to remove stale
stored values
- `php artisan test --compact tests/Browser/OnboardingFlowTest.php`
still has one unrelated real-estate onboarding browser failure (`it
creates a real estate account during onboarding when feature is
enabled`)
2026-04-17 10:20:05 +02:00
Víctor Falcón 2f583c0113
Cancel Enable Banking connections for free users (#289)
## Summary
- add month-end command to revoke old Enable Banking sessions for users
without a paid plan while keeping their linked accounts as manual
accounts
- extract banking disconnect flow into a reusable action so manual
disconnects and scheduled cleanup share same revoke and detach behavior
- add feature coverage for free-user cleanup, paid-user skips,
under-6-hour skips, non-Enable Banking skips, and revoke failure
fallback

## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/CancelFreeEnableBankingConnectionsCommandTest.php
tests/Feature/OpenBanking/ConnectionControllerTest.php
2026-04-15 16:23:03 +02:00
Víctor Falcón 5b78509588
feat: resend verification emails to unverified leads (#287)
## Summary

- Adds `leads:resend-verification-emails` artisan command that
dispatches `VerifyUserLeadEmailNotification` to all leads where
`email_verified_at IS NULL`
- Supports `--dry-run` flag to preview the count without sending
- Follows the same pattern as `leads:retry-failed-jobs` (progress bar,
summary table)

## Test plan

- [ ] `--dry-run` shows correct count without dispatching
- [ ] Command dispatches exactly one notification per unverified lead
- [ ] Verified leads are skipped
- [ ] Early exit when no unverified leads exist
2026-04-15 09:13:27 +01:00
Víctor Falcón f408dbe4c8
feat: selective retry of failed lead email jobs (#286)
## Summary

- Adds `leads:retry-failed-jobs` artisan command to selectively retry
failed `emails`-queue jobs after a Resend rate-limit incident
- Retries jobs for verified leads and `VerifyUserLeadEmailNotification`
for unverified leads; forgets jobs for deleted leads (DDoS cleanup) or
unverified leads receiving waitlist emails
- Adds `deleteWhenMissingModels = true` to all lead mail/notification
classes so future jobs for deleted leads are silently discarded instead
of failing

## Usage

```bash
# Preview (no changes)
php artisan leads:retry-failed-jobs --dry-run

# Execute
php artisan leads:retry-failed-jobs
```

## Test plan

- [x] `leads:retry-failed-jobs` forgets jobs for deleted leads
- [x] `leads:retry-failed-jobs` retries jobs for verified leads
- [x] `leads:retry-failed-jobs` forgets waitlist jobs for unverified
leads
- [x] `leads:retry-failed-jobs` retries
`VerifyUserLeadEmailNotification` for unverified leads
- [x] `leads:retry-failed-jobs` handles mixed job types correctly
- [x] `--dry-run` does not modify `failed_jobs`
2026-04-15 08:00:29 +01:00
Víctor Falcón d0aab3d11b
feat: verify waitlist leads (#285)
## Summary
- gate waitlist signup behind email confirmation so only verified leads
receive a queue position and referral code
- add signed lead verification routes, a check-email page, and a
dedicated verification email for the waitlist flow
- update lead syncing and feature tests so only verified leads are
exported to Resend and referral movement happens after verification

## Testing
- php artisan test --compact tests/Feature/UserLeadTest.php
- php artisan test --compact
tests/Feature/ResendSyncLeadsCommandTest.php
- php artisan test --compact tests/Feature/MailSenderTest.php
- vendor/bin/pint --dirty --format agent
2026-04-14 11:26:01 +01:00
Víctor Falcón dc0695c2ca
feat: sync user leads to resend (#283)
## Summary
- add a `resend:sync-leads` command that syncs all `user_leads` into the
Resend leads segment
- make lead sync idempotent by creating contacts with the segment and
falling back to adding existing contacts to the segment
- schedule the command daily at `03:00` UTC and cover the
command/fallback behavior with Pest tests

## Testing
- php artisan test --compact
tests/Feature/ResendSyncLeadsCommandTest.php
2026-04-13 19:56:08 +01:00
Víctor Falcón c42a48a952
chore: Remove account-mapping feature flag (#252)
## Summary

- Removes the `account-mapping` Pennant feature flag entirely, making
the account mapping flow (pending accounts data + map-accounts page) the
default and only code path
- Removes the old direct-creation branches from all banking controllers
(Authorization, Bitpanda, Binance, IndexaCapital)
- Extracts a shared `CreatesAccountsFromPending` trait for the
onboarding auto-create logic
- Updates all controller tests to reflect the always-on mapping behavior

## Changes

### Backend
- **AppServiceProvider** — removed `account-mapping` flag definition
- **AuthorizationController** — removed flag check +
`createAccountsFromSession()` dead code; always stores
`pending_accounts_data` and redirects to mapping
- **BitpandaController / BinanceController / IndexaCapitalController** —
removed flag checks, old inline account creation, and unused imports
- **HandleInertiaRequests / ActivateDevelopmentFeatures /
ResolvesFeatures** — removed `account-mapping` from flag arrays
- **New `CreatesAccountsFromPending` trait** — shared auto-create logic
for onboarding path (to be consumed next)

### Frontend
- Removed `'account-mapping'` from the TypeScript `Features` interface

### Tests
- Merged flag-specific test variants into single always-on tests
- Removed redundant tests that tested the old disabled-flag code path
- Updated assertions to check `pending_accounts_data` instead of direct
account creation
2026-04-01 12:09:22 +02:00
Víctor Falcón f3b5929ecc
fix(banking): retry failed sync connections and log every sync attempt (#251)
## Summary

- **Fixes the broken retry mechanism** — after the first failed attempt
set status to `Error`, subsequent retry attempts bailed out because
`isActive()` returns `false` for `Error`. Now both `Active` and `Error`
statuses are syncable.
- **Adds auto-retry across scheduled runs** —
`SyncAllBankingConnectionsJob` and `banking:sync` command now include
`Error` connections where `consecutive_sync_failures < 3` (configurable
via `MAX_SCHEDULED_RETRIES`). After 3 full dispatch cycles, manual
intervention is required.
- **Logs every sync attempt to DB** — new `banking_sync_logs` table
records status (Success/Failed/Skipped), attempt number, error details,
duration, and metadata for each sync.

## Changes

### Core logic (`SyncBankingConnectionJob`)
- `isSyncableStatus()` allows both `Active` and `Error` through the gate
- Temporary errors: status only set to `Error` on the final attempt
(attempt 3); earlier attempts re-throw without changing status
- Permanent auth errors (401/403): `$this->fail()` called immediately,
`consecutive_sync_failures` set beyond the cap
- Rate limit (429): handled silently (existing behavior preserved)
- Every attempt is logged to `banking_sync_logs`

### Query updates
- `SyncAllBankingConnectionsJob`: includes `Error` connections under
retry cap
- `SyncBankingConnections` command: same query update for
`--user`/`--connection` filtered runs

### Controller updates
- `ConnectionController::sync()` and `updateCredentials()` reset
`consecutive_sync_failures` to 0

### New files
- `BankingSyncLogStatus` enum (Success, Failed, Skipped)
- `BankingSyncLog` model
- Two migrations: `add_consecutive_sync_failures` column,
`create_banking_sync_logs` table

### Tests
- Updated 3 existing auth error tests in `SyncBankingConnectionJobTest`
(24 pass)
- Added 17 new tests in `SyncRetryAndLoggingTest` covering retry
behavior, sync logging, scheduled retry inclusion/exclusion, and manual
retry reset
- All 10 `SyncBankingConnectionsCommandTest` tests still pass
2026-03-31 11:34:35 +01:00
Víctor Falcón bb65bdc16e
feat(accounts): add loan amortization projections for loan accounts (#246)
## Summary

- Adds loan detail tracking (interest rate, term, start date, original
amount) to loan-type accounts, following the existing
`real_estate_details` pattern
- Implements `LoanAmortizationService` with standard amortization math
to automatically project month-to-month balance evolution
- Shows projected future balances as a dashed line on the account
balance chart, visually distinct from historical data
- Adds a scheduled command (`loans:generate-balances`) to auto-generate
monthly balance entries for loan accounts
- Includes 36 tests (10 unit for pure math, 26 feature for CRUD, API
projections, command, and authorization)

## Changes

### Backend
- **Migration**: `loan_details` table (UUID PK, unique `account_id` FK,
interest rate, term, start date, original amount)
- **LoanDetail model** + factory with `Account::loanDetail()` HasOne
relationship
- **LoanAmortizationService**: `calculateMonthlyPayment`,
`calculateRemainingBalance`, `generateProjection`, `projectFromBalance`,
`calculateRemainingMonths`, `getBalanceAtDate`
- **GenerateMonthlyLoanBalances** command: runs monthly on 1st, skips
existing entries
- **LoanDetailController**: PATCH endpoint for editing loan details from
show page
- **Settings\AccountController**: creates/updates `LoanDetail` on
store/update
- **DashboardAnalyticsController**: appends projected data points with
`projected: true` flag
- **Validation**: loan-specific rules in StoreAccountRequest and
UpdateAccountRequest

### Frontend
- **AccountForm**: loan fields (rate, term, start date, original amount)
shown when `type === 'loan'`
- **Create/Edit dialogs**: pass loan data fields in POST/PATCH payloads
- **Show page**: `LoanDetailsCard` component with edit/read modes,
computed monthly payment and remaining months
- **Balance chart**: new `ComposedChart` branch with dashed `Line`
overlay for projected data points
- **Types**: `LoanDetail` interface in `account.ts`
2026-03-26 15:06:09 +01:00
Víctor Falcón fa11dc78e0
feat(accounts): add market value and annual revaluation to real estate accounts (#245)
## Summary

- Allow setting the **current market value** (balance) when creating a
real estate account — the balance field existed for other account types
but was hidden for real estate
- Add an **annual revaluation percentage** field to real estate
accounts, editable from both the creation form and the property details
card on the account show page
- Create a scheduled command (`real-estate:apply-revaluation`) that runs
monthly on the 1st to automatically adjust property values by
`revaluation_percentage / 12 / 100`

## Details

### Backend
- Migration adds `decimal('revaluation_percentage', 5, 2)` nullable
column to `real_estate_details`
- Validation: nullable numeric, range -100 to +100 (negative for
depreciation)
- `ApplyRealEstateRevaluationCommand` finds all accounts with a
non-null/non-zero percentage, gets the latest balance, and upserts the
new balance for today
- Scheduled `->monthlyOn(1, '00:00')` in `routes/console.php`

### Frontend
- Added `real_estate` to `BALANCE_ACCOUNT_TYPES` so the Market Value
field appears during creation
- Added Annual Revaluation (%) input to both the creation form and the
property details card
- Read-mode displays formatted percentage with sign (e.g.,
"+3.50%/year")

### Tests
- 6 new tests in `RealEstateTest.php` covering creation with balance,
revaluation %, negative %, validation, PATCH update, and clearing to
null
- 8 tests in `ApplyRealEstateRevaluationTest.php` covering
positive/negative revaluation, skip conditions, latest balance usage,
multiple accounts, and upsert behavior
2026-03-26 11:02:20 +01:00
Víctor Falcón 1880333b1c
chore: upgrade Laravel 12 to 13 (#242)
## Summary

- Upgrade `laravel/framework` from v12 to **v13.1.1** and update all 52
dependencies to their latest versions
- Bump `laravel/tinker` from v2 to **v3.0** (required for Laravel 13
compatibility)
- Address Laravel 13 breaking change: add `serializable_classes =>
false` to `config/cache.php`
- Fix cached `Collection` in `routes/web.php` — converted to plain array
via `->toArray()` for serialization safety

## Changes

| File | What changed |
|------|-------------|
| `composer.json` | Bumped `php ^8.3`, `laravel/framework ^13.0`,
`laravel/tinker ^3.0` |
| `composer.lock` | 52 packages updated, 1 removed
(`symfony/polyfill-php83`) |
| `config/cache.php` | Added `serializable_classes => false` |
| `routes/web.php` | Cached query result uses `->toArray()`, fallback
changed from `collect()` to `[]` |

## Testing

Full test suite passing: **919 tests, 3792 assertions, 0 failures**
2026-03-25 12:56:33 +00:00
Víctor Falcón 395c4ad2c3
feat(accounts): add real estate asset tracking (#241)
## Summary

- Adds **real estate** as a new account type (`real_estate`) for
tracking property assets within the existing Accounts page
- Properties store metadata (type, address, purchase price/date, area,
notes) in a dedicated `real_estate_details` table with a one-to-one
relationship to accounts
- Properties can be **linked to a loan account** (mortgage) to
implicitly calculate equity — property value counts as asset (+), linked
loan counts as liability (-)
- Market value is tracked as the account balance and uses "market value"
terminology throughout the UI

## What's included

### Backend
- `PropertyType` enum (Residential, Commercial, Land, Vacation, Other)
- `RealEstateDetail` model, factory, migration, policy
- `StoreRealEstateDetailRequest` and `UpdateRealEstateDetailRequest`
form requests
- `RealEstateDetailController` with `update()` for editing property
details
- Extended `Settings\AccountController@store` to create
`RealEstateDetail` when type is `real_estate`
- Extended `AccountController@show` to load real estate detail, linked
loan with bank info, and available loan accounts
- Extended `AccountController@index` SQL ordering to include
`real_estate`
- Conditional validation rules in `StoreAccountRequest` for real estate
fields

### Frontend
- New `real_estate` type in `account.ts` with `PropertyType`,
`AreaUnit`, `RealEstateDetail` interface, and helper functions
- Conditional real estate fields in `account-form.tsx` (property type,
address, purchase price, purchase date, area, linked loan, notes)
- `PropertyDetailsCard` component in `Accounts/Show.tsx` with view and
inline edit modes
- "Update market value" terminology in balance update buttons for real
estate accounts
- `real_estate` added to account type ordering and groups on the Index
page
- Wayfinder routes regenerated

### Tests
- 22 feature tests covering creation, validation, show page, index
ordering, updating details, IDOR protection, model relationships, and
soft delete behavior
- Unit test updates for `AccountType` enum (`reducesNetWorth`,
`isNonTransactional`)

### Translations
- 32 new Spanish translation strings for all real estate UI

## Design decisions
- Real estate accounts are **non-transactional** (like
investment/retirement) — balance-only tracking for now. Future iteration
will link transactions directly for rental income/expense tracking
- **Single loan per property** via direct FK from
`real_estate_details.linked_loan_account_id`
- No encryption on address/notes fields (opted out per discussion)
- No separate sidebar page — real estate is a grouped section within the
existing Accounts page
2026-03-24 10:21:32 +00:00
Víctor Falcón 08dfb07a90
fix(banking): correct backfill-ibans endpoint and handle expired sessions gracefully (#222)
## Problem

`banking:backfill-ibans` was calling `GET /accounts/{uid}` but the
correct Enable Banking endpoint is `GET /accounts/{uid}/details`. This
caused every account to return 404, and all 404s were counted as
failures making the command exit with failure status.

Additionally, accounts whose sessions have expired or been revoked will
always return 404 — this is expected and should not be treated as an
error.

## Changes

- **`EnableBankingProvider`** — fix endpoint from `/accounts/{uid}` to
`/accounts/{uid}/details`
- **`BackfillAccountIbans`** — catch `RequestException` 404s separately
and count them as `expired/revoked session` skips rather than failures;
command exits successfully when the only issues are expired sessions
- **Tests** — add test for the 404 expired-session path; update output
string assertions

## Expected output after fix

```
Found 5 account(s) with missing IBAN.
IBAN updated for 2 account(s). Skipped (no IBAN in API response): 0. Skipped (expired/revoked session): 3. Failed: 0.
```

The 2 active-session accounts will be backfilled; the 3 from the
expired/revoked connection will be skipped cleanly.
2026-03-12 11:57:54 +00:00
Víctor Falcón 07ab9d5b96
feat(banking): add banking:backfill-ibans command to populate missing IBANs (#221)
## Summary

- Adds `banking:backfill-ibans` artisan command to backfill null `iban`
values on Enable Banking accounts by calling `GET /accounts/{uid}` for
each affected account.
- Adds `getAccount()` to `BankingProviderInterface` and
`EnableBankingProvider`.
- 9 feature tests covering all paths: happy path, no-IBAN skip, dry-run,
user/connection filters, API failure handling.

## Why

Accounts connected before
[#220](https://github.com/whisper-money/whisper-money/pull/220) was
deployed have `null` IBANs. Without IBANs, reconnects fall back to
positional matching which is less reliable. Running this command once in
production populates the IBAN for all existing accounts so future
reconnects use the safer IBAN-based strategy.

## Usage

```bash
# Dry run first
php artisan banking:backfill-ibans --dry-run

# Run for all accounts
php artisan banking:backfill-ibans

# Scope to a specific user or connection
php artisan banking:backfill-ibans --user=user@example.com
php artisan banking:backfill-ibans --connection=<connection-id>
```
2026-03-12 10:58:55 +00:00
Víctor Falcón cbe28ff708
fix(banks:set-logo): add JPEG support test coverage and prompt for missing arguments (#214)
## Summary

- Fixes `banks:set-logo` for JPEG images by adding a `makeJpeg()` test
helper and a full JPEG test case — the pipeline was working but
completely untested, meaning any regression would go undetected
- Implements `PromptsForMissingInput` so the command interactively
prompts for `bank` and `url` when they are not passed as arguments
2026-03-07 16:33:54 +00:00
Víctor Falcón 2f1b9065f0
Add banks:set-logo artisan command (#213)
## Summary

- Adds `banks:set-logo {bank} {url}` artisan command to download,
process, and assign a logo to a bank by UUID
- Installs `intervention/image:^3.0` (GD driver) for image processing
- Image is validated to be square (returns an error if not), resized
down to max 250×250px if needed, stored as PNG on the `public` disk at
`banks/logos/{uuid}.png`, and the bank's `logo` field is updated with
the public URL

## Usage

```
php artisan banks:set-logo <bank-uuid> <image-url>
```

## Tests

7 feature tests covering success, no-resize, bank not found, HTTP
failure, non-image content type, non-square image, and invalid image
content.
2026-03-07 15:19:28 +00:00
Víctor Falcón 2763846329
Add weekly bank logo audit command (#211)
## Summary
- add a new `banks:check-logos` console command that validates all
non-null bank logo URLs weekly
- set broken/invalid bank logos to `null` and send an admin report email
to `ADMIN_EMAIL` when updates occur
- add weekly scheduling, admin mail config wiring, and feature tests for
valid/broken/head-fallback flows

## Testing
- vendor/bin/pint --dirty
- php artisan test tests/Feature/Console/CheckBankLogosCommandTest.php

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-06 11:05:13 +00:00
Víctor Falcón ac1476eeff
feat(pricing): dynamic Stripe pricing with locale-aware formatting (#204)
## Summary

- **Dynamic Stripe price resolution**: Replaces hardcoded
`stripe_price_id` env vars with lookup-key-based resolution
(`stripe_lookup_key`). A new `php artisan stripe:sync-prices` command
creates/updates Stripe prices from `config/subscriptions.php`
automatically.
- **Locale-aware currency formatting**: Replaces all
`getCurrencySymbol() + toFixed(2)` patterns with `formatCurrency()`
(backed by `Intl.NumberFormat`) across `welcome.tsx`, `paywall.tsx`,
`billing.tsx`, and `step-create-account.tsx`, so symbol position and
separators are correct for the user's locale (e.g. `3,90 €` in Spanish).
- **EUR defaults and updated plan prices**: Cashier currency defaulted
to EUR, plan prices updated to €7.80/month and €46.80/year, and
`pricing.currency` is now shared as an Inertia prop.
- **Promo/discount cleanup**: Removed all FOUNDER discount mentions and
Discord community links from the paywall, landing pricing section, and
invitation email.
2026-03-05 11:41:59 +00:00
Víctor Falcón 3e087bdcd7
fix(static-analysis): clear phpstan-baseline by fixing all suppressed errors (#183)
## 🚪 Why?

### Problem

PHPStan was running with a baseline of 56 suppressed errors, meaning
static analysis was not enforcing type safety across a significant
portion of the codebase. These errors were real type mismatches,
redundant null-safety operators, and incorrect PHPDoc annotations that
could mask bugs and make the code harder to reason about.

## 🔑 What?

### Changes

- Add `@property` PHPDoc annotations to `Account`, `BankingConnection`,
`ExchangeRate`, and `Transaction` models so Enum casts and typed columns
are visible to PHPStan
- Add `instanceof User` guards in `ScheduleDripEmailsListener`,
`SyncUserToResendListener`, and `FortifyServiceProvider` to properly
narrow `Authenticatable` to `App\Models\User`
- Remove redundant `?? false` and unnecessary nullsafe `?->value` in
`HandleInertiaRequests`
- Fix `SyncBankingConnectionJob`: use `->name` instead of `?->name` on
an always-loaded `bank` relation
- Remove `is_countable()` guard in `BalanceLookup` (parameter is always
`Collection|array`, both countable)
- Remove `?? []` / `?? default` fallbacks on fully-typed array keys
across `BalanceSyncService`, `BinanceBalanceSyncService`,
`BinanceClient`, `BitpandaBalanceSyncService`, `BitpandaClient`,
`IndexaCapitalClient`, `IndexaCapitalBalanceSyncService`, and
`AuthorizationController`
- Fix `BinanceClient::publicClient()` `retry()` call: use `when:` named
argument and `\Throwable` type hint to match `PendingRequest::retry()`
signature
- Update `IndexaCapitalClient::getPerformance()` `@return` to include
`portfolios` and `net_amounts` keys; simplify sync service to remove
dead null checks
- Replace nullsafe chain with ternary in `BudgetPeriodService`
- Replace `match` statement in `SetupMainUser` with `if/else` to
eliminate always-true comparison
- Clear `phpstan-baseline.neon` entirely (was 56 suppressed errors, now
0)

##  Verification

### Tests

- Existing tests pass: PHPStan level 5 reports 0 errors with empty
baseline
2026-03-02 12:22:30 +00:00
Víctor Falcón d48fea15b2
perf: make banking syncs incremental on subsequent runs (#141)
## Summary

- Subsequent syncs (every 6h) now only process recent data instead of
re-syncing full history, reducing unnecessary API calls and database
writes
- Full sync still runs automatically on first connection and can be
forced anytime with `banking:sync --full`
- Centralizes `isFirstSync` logic in `SyncBankingConnectionJob` and
propagates the `fullSync` flag through the entire chain: Command →
`SyncAllBankingConnectionsJob` → `SyncBankingConnectionJob` → provider
services

## Changes by provider

- **Indexa Capital**: Skips portfolio entries older than the last
recorded balance date on incremental syncs (the API doesn't support date
filtering, so filtering is done client-side)
- **Binance**: Reuses stored `invested_amount` from the database on
subsequent syncs instead of fetching up to 2 years of deposit/withdrawal
history in 90-day windows
- **EnableBanking / Bitpanda**: Already minimal — no changes needed

## Testing

- Fixed 6 existing Binance tests to pass `isFirstSync: true` for
invested amount calculation
- Added 7 new tests covering incremental sync behavior, full sync
override, and `--full` flag propagation
2026-02-23 15:10:40 +01:00
Víctor Falcón 299b8a56d8
feat: investment benefits — show gains/losses on investment accounts (#140)
## Why

Investment and retirement accounts show balance over time, but there's
no way to see how much money was actually put in versus how much is
current value. Users can't tell at a glance whether their investments
are up or down.

## What

Adds an "invested amount" tracking system across the full stack:

**Backend**
- New `invested_amount` column on `account_balances` (nullable
bigInteger, cents, per-date)
- Auto-sync from providers: Indexa Capital (instruments_cost +
cash_amount), Bitpanda (fiat deposit/withdrawal history), Binance
(90-day windowed deposit/withdrawal with crypto→fiat conversion)
- Manual input support via Update Balance dialog
- Historical invested amount data in all balance evolution APIs (net
worth, account detail)

**Frontend**
- Dashed line on sparkline charts (dashboard + accounts page) showing
per-point historical invested amount alongside balance
- Dashed line on account detail charts (daily AreaChart + monthly
ComposedChart)
- Tooltips with labeled rows: Balance, Invested, Gain/loss (color-coded)
- Invested amount column in balances history modal
- Invested amount field in balance import wizard (CSV mapping)
- Demo account seeder updated with invested amount data

## Screenshots
<img width="1301" height="750" alt="image"
src="https://github.com/user-attachments/assets/0f05ecd0-8b98-47b4-9fa4-027f0311e3bb"
/>
<img width="744" height="374" alt="image"
src="https://github.com/user-attachments/assets/c4daa816-dee0-4f94-957f-317a13bc80d5"
/>
<img width="1267" height="738" alt="image"
src="https://github.com/user-attachments/assets/21df350c-6954-4ff5-8b3c-b858df3a8b3a"
/>
<img width="1301" height="828" alt="image"
src="https://github.com/user-attachments/assets/16f5f021-a926-4e8e-a999-c4ca32d1ea3d"
/>
<img width="1274" height="845" alt="image"
src="https://github.com/user-attachments/assets/62f2dfc0-04f0-4bdb-b072-cf7cd1be77d3"
/>
2026-02-23 13:59:10 +01:00
Víctor Falcón a53e2be57b
Remove encryption from browser tests and demo user (#129)
## Summary

- Removed all `setupEncryptionKey()` / `visitWithEncryptionKey()` calls
from browser tests — encryption key setup in localStorage is no longer
needed since new users don't have encryption
- Removed `encryption_salt` from `UserFactory::onboarded()` state and
`OnboardingFlowTest` user creation
- Removed `name_iv` from `Account::factory()` calls in
`BankAccountsTest`
- Deleted `DemoEncryptionService` and its unit test — demo command now
stores plaintext account names and transaction descriptions
- Removed `demoEncryptionKey` Inertia shared prop and `encryption_key`
from demo config
- Removed encryption helper methods from `TestCase.php` and global
`setupEncryptionKey()` from `Pest.php`

## Test plan

- [x] Run `php artisan test --exclude-testsuite=Browser` — all
non-browser tests pass
- [x] Run `php artisan test --testsuite=Browser` — browser tests pass
without encryption key setup
- [x] Run `php artisan demo:reset` — demo account created with plaintext
data
- [x] Verify existing encryption migration tests still pass
(`EncryptionTest`, `DecryptTransactionsTest`,
`PlaintextTransactionsTest`)
2026-02-17 11:45:27 +01:00
Víctor Falcón b9abf49617
feat: Add --user and --connection filters to banking:sync command (#122)
## Summary
- Added `--user={email}` option to filter sync by a specific user's
email address
- Added `--connection={id}` option to filter sync by a specific banking
connection ID
- Both filters can be combined; when neither is provided, the command
dispatches the bulk sync job as before
- Only active, non-expired connections are synced when filters are used

## Test plan
- [x] 7 new Pest tests covering all filter scenarios (all passing)
- [x] Pint formatting passes
2026-02-13 17:35:07 +01:00
Víctor Falcón 6b05de173a
Remove plaintext-transactions feature flag & E2E references (#116)
## Summary

- Removes the `plaintext-transactions` Pennant feature flag — plaintext
is now the default for all users
- Removes encryption guards and `isPlaintext` conditionals from
transaction create/edit/import flows, keeping only the plaintext code
paths
- Makes `EncryptionKeyButton` conditional — only shown when user has
legacy encrypted accounts or transactions
- Removes encryption onboarding steps (`step-encryption-explained`,
`step-encryption-setup`) and related state/props
- Updates landing page to replace E2E encryption marketing with
privacy-first messaging ("Your Data, Your Rules" section)
- Updates privacy policy to replace E2E encryption claims with accurate
security language (encryption at rest, TLS in transit, no third-party
sharing)
- Cleans up tests to remove feature flag assertions and
`Feature::activate()` calls

**22 files changed, 145 insertions, 730 deletions**

## Test plan

- [x] Create a transaction without encryption key unlocked — should
succeed
- [x] EncryptionKeyButton should not appear in header for users with no
encrypted data
- [x] EncryptionKeyButton should appear for users with legacy encrypted
transactions/accounts
- [x] Landing page has no E2E encryption references, shows new privacy
section
- [x] Onboarding flow has no encryption setup steps
- [x] Privacy policy reflects accurate security language
- [x] Frontend builds successfully (`bun run build`)
- [x] All linting passes (`bun run lint`, `vendor/bin/pint --dirty`)
2026-02-13 11:10:21 +01:00
Víctor Falcón 79164345d9
Fix: Ensure demo account is always email-verified (#118)
## Summary
- Ensure `email_verified_at` is set when the demo account is created or
already exists but is unverified
- `email_verified_at` is not in the User model's `$fillable` array, so
set it directly on the model instead of via `create()`/`update()`

## Test plan
- [x] Added test that an existing unverified demo user gets verified on
`demo:reset`
- [x] Added assertion that newly created demo user has
`email_verified_at` set
- [x] All 3 demo reset tests pass
2026-02-13 07:36:54 +01:00
Víctor Falcón d1d1be7586
Remove budgets feature flag (#108)
## Summary
- Remove the `budgets` Pennant feature flag — budgets is now enabled for
all users
- Delete `EnsureBudgetsFeature` middleware and its route guard
- Remove `budgets` from shared Inertia features and the `Features`
TypeScript interface
- Remove all `Feature::for($user)->activate('budgets')` calls from tests
- Delete `BudgetFeatureFlagTest` and feature-disabled test cases from
`BudgetsFeatureNavigationTest`
2026-02-12 09:58:01 +01:00
Víctor Falcón db7b6e4da7
feat: Integrate EnableBanking as open banking provider (#106)
## Summary

- Adds **EnableBanking** as the first open banking provider, allowing
users to connect real bank accounts and automatically sync transactions
and balances
- Uses a **`BankingProviderInterface`** contract so future providers
(Plaid, GoCardless, etc.) can be added by implementing the same
interface
- Feature-flagged behind the **`open-banking`** Pennant flag (default:
off)
- Connected accounts are **unencrypted** and transactions have `source =
'enablebanking'`

### What's included

**Backend:**
- `BankingProviderInterface` contract + `EnableBankingProvider`
implementation (JWT RS256 auth)
- `BankingConnection` model with full lifecycle (pending →
awaiting_mapping → active → expired/revoked/error)
- `TransactionSyncService` — pagination, deduplication by
`external_transaction_id`, amount/date mapping
- `BalanceSyncService` — preferred balance type selection (CLBD → ITAV
fallback)
- Authorization flow: start auth → bank redirect → callback → session
creation → account mapping → sync
- `SyncBankingConnectionJob` (unique per connection, 3 retries) +
scheduled every 6 hours
- `banking:sync` artisan command
- 5 migrations: `banking_connections` table, account fields, transaction
`external_transaction_id`, `pending_accounts_data`, `linked_at`

**Frontend:**
- Manual vs Connected account choice in the create account dialog
- Multi-step bank connection dialog (country → bank selection →
confirmation → redirect)
- Account mapping page — map discovered bank accounts to existing
accounts, create new ones, or skip
- Settings/Connections page with status badges, sync/disconnect actions
- "Connected" badge on linked accounts in settings

**Tests:**
- 49 tests covering feature flags, controllers, account mapping,
transaction sync, balance sync, deduplication, and pagination

### Feature Flags

This PR introduces **two Pennant feature flags**:

1. **`open-banking`** — Gates the entire open banking feature
(institutions endpoint, authorization flow, connections page). When
disabled, all open banking routes return 404.

2. **`account-mapping`** — Controls whether users see an intermediate
account mapping step after connecting a bank. When **enabled**, users
are redirected to a mapping page where they can choose to create new
accounts, link to existing ones, or skip each discovered bank account.
When **disabled**, all discovered accounts are automatically created
(original behavior). Linked accounts only sync transactions from their
last transaction date and only update the current balance from the
provider (no historical balance calculation or daily balance tracking).

Enable per-user:
```bash
php artisan feature:enable open-banking user@example.com
php artisan feature:enable account-mapping user@example.com
```

Enable for all users:
```bash
php artisan feature:enable open-banking all
php artisan feature:enable account-mapping all
```

## Test plan

- [x] Enable feature flag: `php artisan feature:enable open-banking`
- [x] Verify "Connected" option appears in create account dialog
- [x] Start authorization flow and verify redirect to bank
- [x] With `account-mapping` **disabled**: verify callback creates
accounts directly and dispatches sync
- [x] With `account-mapping` **enabled**: verify callback redirects to
mapping page
- [x] Test mapping page: create new, link to existing, and skip actions
- [x] Verify linked accounts sync only from last transaction date and
only update current balance
- [x] Verify connections page shows "Setup Required" badge for
awaiting_mapping status
- [x] Run `php artisan banking:sync` and verify transactions sync
- [x] Verify connections page shows status, sync, and disconnect actions
- [x] Run full test suite: `php artisan test --compact`
2026-02-12 09:09:28 +01:00
Víctor Falcón e35f7125b3
feat: Plaintext transactions behind feature flag (#105)
## Summary

- Adds `plaintext-transactions` Pennant feature flag (defaults to
`false` / encryption ON)
- When active, new transactions are stored as plaintext (no client-side
encryption)
- Existing encrypted transactions continue to work — detection is based
on `description_iv` being NULL (plaintext) vs present (encrypted)
- Migration makes `description_iv` nullable on the transactions table

## Changes

**Backend:**
- Feature flag definition in `AppServiceProvider`, shared via
`HandleInertiaRequests`
- `StoreTransactionRequest` / `UpdateTransactionRequest` conditionally
require `description_iv`
- `TransactionFactory` gains a `plaintext()` state

**Frontend:**
- Edit dialog and import drawer skip encryption when flag is active
- `EncryptedTransactionDescription` renders plaintext directly when IV
is null
- All decryption loops handle both encrypted and plaintext transactions
- Rule re-evaluation stores notes as plaintext when flag is active

## Test plan

- [x] Run `php artisan migrate` to apply the migration
- [x] Activate flag: `php artisan feature:enable plaintext-transactions
all`
- [ ] Create a transaction — verify description is stored as plaintext
in DB (`description_iv` is NULL)
- [ ] Deactivate flag: `php artisan feature:disable
plaintext-transactions all`
- [ ] Create a transaction — verify encryption is still required (422
without IV)
- [x] Verify old encrypted transactions still display correctly
- [ ] Run `php artisan test --compact
tests/Feature/PlaintextTransactionsTest.php`
2026-02-10 14:31:24 +01:00
Víctor Falcón 00b2ca7c55 fix: Budget period not found on last day of period (#91)
## Summary

- Budget periods use `date` columns (`start_date`, `end_date`) but
queries compared them against `now()` which includes a time component
(e.g. `2026-01-31 15:30:00`). MySQL converts the date to midnight for
comparison, so `end_date >= now()` evaluates to `'2026-01-31 00:00:00'
>= '2026-01-31 15:30:00'` → **false** — causing "No active period" on
the last day of every period.
- Replaced `now()` with `today()` (date-only) in all budget period date
comparisons across `Budget::getCurrentPeriod()`,
`BudgetController::index()`, and `GenerateBudgetPeriods` command.

## Test plan

- [x] Added 4 Pest tests covering the edge case (last day at various
times, first day at end of day, index/show endpoints)
- [x] All existing budget tests pass
2026-02-01 11:33:10 +01:00
Víctor Falcón cc666a3111 test: Fix Carbon month overflow in date calculations (#87)
## Summary
- Replace `subMonths()` with `subMonthsNoOverflow()` across controllers,
commands, and tests to prevent date overflow on months with 31 days
- On Jan 31, `subMonths(2)` overflows: Nov 31 doesn't exist → Carbon
rolls forward to Dec 1, producing wrong date ranges and off-by-one month
counts

## Root cause
Carbon's `subMonths()` allows day overflow. When the target month has
fewer days than the source date, it rolls into the next month. For
example:
- `2026-01-31 subMonths(2)` → Nov 31 → **2025-12-01** (expected Nov 30)
- `2026-01-31 subMonths(11)` → Feb 31 → **2025-03-03** (expected Feb 28)

`subMonthsNoOverflow()` clamps to the last valid day of the target month
instead.

## Files changed
- `app/Http/Controllers/Api/CashflowAnalyticsController.php` — cashflow
trend start date calculation
- `app/Console/Commands/ResetDemoAccountCommand.php` — balance history
date generation (was causing duplicate `balance_date` entries)
- `tests/Feature/AccountControllerTest.php` — balance evolution `from`
param
- `tests/Feature/CashflowAnalyticsTest.php` — transaction date
generation in loop
- `tests/Feature/DashboardAnalyticsTest.php` — net worth evolution
`from` param

## Test plan
- [x] `account balance evolution returns data` — expects 3 months, was
getting 2
- [x] `cashflow trend returns monthly data` — expects 3 months, was
getting 2
- [x] `cashflow trend defaults to 12 months` — expects 12 months, was
getting 11
- [x] `net worth evolution returns monthly data points` — expects 3
months, was getting 2
- [x] `demo:reset creates demo user` — was throwing
UniqueConstraintViolation on duplicate balance dates
2026-02-01 11:33:09 +01:00
Víctor Falcón 952a5d4be7
feat: Sync new users to Resend contacts (#85)
## Summary
- Add `SyncUserToResendListener` to automatically sync users to Resend
contacts on registration
- Add `ResendService` for Resend API interactions
- Add `resend:sync` command to bulk sync existing users

## Changes
- **New**: `app/Listeners/SyncUserToResendListener.php` -
Auto-discovered queued listener
- **New**: `app/Services/ResendService.php` - Service for Resend
contacts API
- **New**: `app/Console/Commands/ResendSyncCommand.php` - Bulk sync
command
- **New**: Tests for listener and command

## Usage
New users are automatically synced on registration. For existing users:
```bash
php artisan resend:sync
```

## Test plan
- [x] New user registration triggers contact sync
- [x] `resend:sync` command syncs all existing users
- [x] Graceful handling when API key is not configured
- [x] Duplicate contacts are handled by Resend (no errors)
2026-01-28 21:25:58 +01:00
Víctor Falcón 9b6c30775f
Add Budgeting Feature to Track and Manage Spending (#36)
## Overview

We're excited to introduce budgeting capabilities to Whisper Money! This
feature helps you take control of your finances by setting spending
limits and tracking your progress over time.

## Screenshots
<img width="1316" height="793" alt="image"
src="https://github.com/user-attachments/assets/ac394d36-cded-4ea4-9883-120785e260f1"
/>
<img width="1315" height="907" alt="image"
src="https://github.com/user-attachments/assets/7c682474-5aa7-4388-b626-29b56f5ebbef"
/>
<img width="1315" height="992" alt="image"
src="https://github.com/user-attachments/assets/21eace45-23c6-472d-9aa0-0feb6db3fba4"
/>

## What's New

### Create Flexible Budgets
- Set budgets for specific categories or labels
- Choose from monthly, weekly, bi-weekly, or custom periods
- Set your own budget start date for better alignment with your pay
schedule

### Track Your Spending
- Visual spending charts show how much you've spent vs. your budget
- See at a glance which budgets are on track and which need attention
- View all transactions that count toward each budget

### Smart Budget Management
- **Carry Over**: Unused budget amounts automatically roll into the next
period
- **Reset**: Unused amounts return to your available money pool
- Edit or delete budgets anytime as your needs change

### Easy Access
- New Budgets section in the main navigation
- Quick overview cards showing budget status
- Detailed budget pages with spending history and transaction lists

## How It Works

1. Create a budget by selecting a category or label and setting your
spending limit
2. Your transactions are automatically matched to relevant budgets
3. Track your progress with visual charts and spending summaries
4. Adjust your budgets as needed to stay on track with your financial
goals

This feature is now available behind a feature flag and can be enabled
for users who want to start budgeting their expenses.
2026-01-21 15:25:50 +01:00
Víctor Falcón 8ac25200dc fix: delay emails to avoid reaching daily resend limit 2026-01-09 11:11:38 +01:00
Víctor Falcón 683b3f32a7
feat: Send custom emails to users (#52)
## Overview

This PR adds a flexible system for sending update emails to all Whisper
Money users. As the solo developer, you can now easily communicate
product updates, announcements, and news to your growing user base.

## Problem Solved

- **No easy way to send announcements**: Previously, there was no simple
way to broadcast important updates to all users
- **Manual process**: Would require writing custom scripts each time
- **No duplicate prevention**: Risk of accidentally sending the same
email multiple times
- **Version control**: Email content wasn't tracked in git

## Solution

A complete email system that lets you:
1. Write update emails as markdown templates (version controlled)
2. Send them with a single command
3. Automatic duplicate prevention (users only receive each update once)
4. Track who received what in the database

## How It Works

### 1. Create Email Template

Create a markdown file in `resources/views/mail/updates/`:

```blade
<x-mail::message>
# What's New in January 2026

Hi {{ $user->name }},

Your update content here...

<x-mail::button :url="'https://discord.gg/9UQWZECDDv'">
Join the Discord Community
</x-mail::button>

Víctor Falcón Ruíz
Founder & Solo Developer, Whisper Money
</x-mail::message>
```

### 2. Send to All Users

```bash
# Simple - one command
php artisan email:update first-update-jan-2026

# With options
php artisan email:update first-update-jan-2026 --subject="Personal Thank You" --exclude-demo
```

### 3. Automatic Tracking

- Users only receive each update once (tracked by identifier)
- Can send different updates to same users
- View history in `user_mail_logs` table

## First Email Included

The PR includes the first update email (`first-update-jan-2026`)
announcing:
- 240+ GitHub stars milestone
- First paying users
- Discord community invitation
- Canny roadmap links
- Personal message from Víctor as the solo developer

## Technical Details

### What's New

**Database:**
- Migration: Add `email_identifier` column to `user_mail_logs`
- Migration: Update unique constraint to `(user_id, email_type,
email_identifier)`

**Backend:**
- `DripEmailType` enum: Added `Update` case
- `UpdateEmail` mailable: Generic mailable accepting any view
- `SendUpdateEmailJob`: Handles sending + duplicate prevention
- `SendUpdateEmailCommand`: Artisan command with progress bar
- Updated all 6 drip email jobs to support new constraint

**Frontend:**
- Email template directory: `resources/views/mail/updates/`
- Comprehensive README with examples

**Tests:**
- Complete test suite: 11 tests covering all scenarios
- Tests duplicate prevention, filtering, queue behavior

### Command Options

```bash
php artisan email:update <view> [identifier] [options]

Arguments:
  view          Template name (e.g., "jan-2026-updates")
  identifier    Tracking ID (defaults to view name)

Options:
  --subject=    Custom email subject
  --exclude-demo   Skip demo account
  --force       Skip confirmation prompt
```

## Benefits

1. **Simple**: One command to send any update email
2. **Flexible**: Just create a new markdown view, no code changes needed
3. **Safe**: Confirmation prompt and duplicate prevention
4. **Fast**: Queued delivery, non-blocking
5. **Tracked**: Full audit trail in UserMailLog
6. **Consistent**: Follows existing drip email patterns
7. **Reusable**: Same command for all future update emails
8. **Version Controlled**: Email content is in git, reviewable

## Migration Path

To use in production:

```bash
# 1. Run migrations
php artisan migrate

# 2. Create your email template
vim resources/views/mail/updates/your-update.blade.php

# 3. Commit and deploy
git add resources/views/mail/updates/your-update.blade.php
git commit -m "Add update email"
git push

# 4. Send on production
php artisan email:update your-update
```

## Example Use Cases

- Product announcements
- New feature launches  
- Community updates
- Milestone celebrations
- Important notifications
- Partnership announcements

## Files Changed

### Created
- `database/migrations/*_add_email_identifier_to_user_mail_logs.php`
- `database/migrations/*_update_user_mail_logs_unique_constraint.php`
- `app/Mail/UpdateEmail.php`
- `app/Jobs/SendUpdateEmailJob.php`
- `app/Console/Commands/SendUpdateEmailCommand.php`
- `resources/views/mail/updates/first-update-jan-2026.blade.php`
- `resources/views/mail/updates/README.md`
- `tests/Feature/Console/SendUpdateEmailCommandTest.php`

### Modified
- `app/Enums/DripEmailType.php` (added Update case)
- `app/Models/UserMailLog.php` (added email_identifier field)
- All 6 drip email jobs (added email_identifier support)

## Testing

All tests passing (11 tests, 27 assertions):
-  Command dispatches jobs for all users
-  Command excludes demo account when flag is set
-  Command fails when view does not exist
-  Command uses custom subject when provided
-  Job is dispatched to emails queue
-  Command handles empty user database gracefully
-  Job skips users who already received the update
-  Job creates mail log entry after sending
-  Job sends email with correct view and user data
-  Command requires confirmation by default
-  Command skips confirmation with force flag
2026-01-09 09:33:19 +01:00
Víctor Falcón fc5cb67fe3 feature: enable cashflow for everyone 2026-01-07 11:06:54 +01:00
Víctor Falcón 9bd1fcea37
Demo Account Experience (#51)
## Summary

<img width="1220" height="1001" alt="whispermoney test_"
src="https://github.com/user-attachments/assets/c35751d5-385b-449c-81d6-14b5b6577ff2"
/>

Introduces a fully-functional demo account that lets prospective users
explore Whisper Money without creating an account. Users can click
"Check Demo" on the welcome page to instantly access a pre-populated
account with realistic financial data spanning 12 months.

## What's New

**Try Before You Sign Up**
- New "Check Demo" button on the welcome page for instant access
- Pre-configured demo account with real-world financial scenarios
- 12 months of sample transactions across multiple account types
(checking, savings, credit cards, investments)
- Pre-built automation rules, labels, and categories to showcase the
full app experience

**Demo Account Limitations**
- Demo accounts are read-only for sensitive operations (can't change
password, email, or payment settings)
- Clear messaging throughout the UI when demo restrictions apply
- Settings pages show helpful notices about demo limitations
- Automatic daily reset to maintain fresh demo experience

**Developer Experience**
- `php artisan demo:reset` command for manual resets
- Configurable via environment variables (DEMO_EMAIL, DEMO_PASSWORD,
DEMO_ENCRYPTION_KEY)
- Comprehensive test coverage for demo restrictions and data generation

## User Impact

This feature removes the friction of signing up before understanding the
product's value. Users can:
- Explore all features with realistic data
- Test automation rules and see them in action
- View charts and insights based on a year of financial activity
- Experience the full privacy-first encryption workflow
- Understand the product before committing to create an account

Perfect for demos, screenshots, documentation, and helping users make
informed decisions about whether Whisper Money fits their needs.
2026-01-07 10:58:14 +01:00
Víctor Falcón 1cec4a6421
Add Cashflow Analytics Feature (#49)
Summary
- Add new Cashflow page with income/expense analytics, Sankey chart
visualization, and trend analysis
- Add Cashflow summary widget to the dashboard
- Implement feature flag system using Laravel Pennant to control feature
visibility per user
- Add artisan commands to enable/disable features: `php artisan
feature:enable/disable [feature] [email|all]`

## New Features
- **Cashflow Page**: Period-based income/expense breakdown with category
analysis
- **Sankey Chart**: Visual flow diagram showing money movement between
categories
- **Trend Chart**: 12-month historical cashflow visualization
- **Dashboard Widget**: Quick cashflow summary card
- **Feature Flags**: Cashflow feature hidden behind feature flag,
controllable per-user

## How to enable it
This feature it's under a feature flag until we are sure it's working
fine. If you want to test it, [send us a message on the general channel
in our discord](https://discord.gg/9UQWZECDDv). If you're self hosting
just run `php artisan feature:enable CashflowFeature {your_email}`.

## New Cashflow Page
<img width="1278" height="1185" alt="image"
src="https://github.com/user-attachments/assets/83469f24-9f96-4e5f-bef9-86c076d31243"
/>
<img width="1274" height="1035" alt="image"
src="https://github.com/user-attachments/assets/8949ff9d-c370-4fc4-81c6-268307c587a5"
/>

## New Dashboard Widget
<img width="1180" height="289" alt="image"
src="https://github.com/user-attachments/assets/ad984a66-9ffd-4079-85ce-0b2d28278d76"
/>
<img width="1179" height="286" alt="image"
src="https://github.com/user-attachments/assets/bff60286-8ea2-4e9c-8fa6-22755566648f"
/>
2026-01-05 13:06:50 +01:00
Víctor Falcón beecff404a
Add subscription cancellation email with manual trigger command (#47)
## Summary

- Add a subscription cancellation email that can be manually triggered
via artisan command
- Include CONTINUE50 coupon code (50% off current and future payments
for monthly/yearly subscriptions)
- Add option to disable all drip emails via `DRIP_EMAILS_ENABLED` env
var

## Usage

```bash
# Send to single user
php artisan email:subscription-cancelled user@example.com

# Send to multiple users
php artisan email:subscription-cancelled user1@example.com,user2@example.com

# Disable all drip emails
DRIP_EMAILS_ENABLED=false
```
2025-12-30 20:32:44 +01:00
Víctor Falcón b88df76ce9 command: send email to leads 2025-12-30 07:22:18 +01:00
Víctor Falcón 53c124a01f chore(commands): unify command signatures 2025-12-30 07:22:18 +01:00