Commit Graph

11 Commits

Author SHA1 Message Date
Víctor Falcón 690be20f21
feat(open-banking): add update credentials flow for API-key connections (#154)
## Why

### Problem

When API tokens for Indexa Capital, Binance, or Bitpanda expire or are
revoked, syncs fail silently with 401/403 errors. Users have no way to
replace their credentials without disconnecting and recreating the
entire connection (losing account mappings and history).

## What

### Changes

- **Auth failure email notification**: on the final retry attempt (3rd
of 3), if a sync job fails with 401/403 for an API-key provider, an
email is sent to the user with a link to the connections settings page
- **Update credentials endpoint**: `PATCH
/settings/connections/{connection}/credentials` validates new
credentials against the provider API before saving, then triggers a sync
- **Update credentials dialog**: provider-specific form (API token for
Indexa/Bitpanda, API key + secret for Binance) shown via an "Update
Credentials" button in the error banner and dropdown menu
- **Mailable**: `BankingConnectionAuthFailedEmail` follows existing
patterns (queued, rate-limited, Markdown template)
- **Form request**: `UpdateConnectionCredentialsRequest` with dynamic
validation rules per provider and authorization check

### Files changed

| File | Change |
|------|--------|
| `app/Jobs/SyncBankingConnectionJob.php` | Send auth failed email on
final attempt for auth errors on API-key providers |
| `app/Mail/BankingConnectionAuthFailedEmail.php` | New queued mailable
|
| `resources/views/mail/banking-connection-auth-failed.blade.php` |
Email template |
| `app/Http/Controllers/OpenBanking/ConnectionController.php` |
`updateCredentials()` action with provider credential validation |
| `app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php`
| Dynamic validation per provider |
| `routes/settings.php` | PATCH route for credential updates |
| `resources/js/components/open-banking/update-credentials-dialog.tsx` |
Dialog with provider-specific fields |
| `resources/js/pages/settings/connections.tsx` | Update Credentials
button in error state and dropdown |

## Verification

### Tests

- **12 new tests** across 2 test files, all passing:
- `SyncBankingConnectionJobTest`: 5 tests covering email sent on final
retry (Indexa 401, Binance 403), not sent before final retry, not sent
for non-auth errors, not sent for EnableBanking
- `ConnectionControllerTest`: 7 tests covering valid credential update
for each provider, invalid credentials, EnableBanking rejection,
authorization, feature flag, required field validation
- Full OpenBanking test suite: **145 tests, 473 assertions** passing
2026-02-25 13:41:24 +01: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 fe76c2e43d
feat: Add Bitpanda exchange integration (#132)
## Summary

- Add Bitpanda as a new exchange provider using a single API key
(`X-Api-Key` header) to sync crypto and fiat wallet balances
- Follow the same architecture as Binance: provider string on
`banking_connections`, dedicated API client, balance sync service,
controller, form request, job wiring, factory state, route, and frontend
dialog
- Convert crypto wallet balances to the user's target currency via
`CurrencyConversionService`; fiat wallets are added directly or
converted if in a different currency
- Bitpanda appears in all countries in the connect dialog (same as
Binance)
- 18 new tests covering controller validation, balance sync scenarios,
sync job delegation, expiry handling, and email suppression
2026-02-19 09:26:31 +01:00
Víctor Falcón df9fc38562
feat: Add Binance integration (#131)
## Summary

- Adds Binance as a third banking provider alongside EnableBanking and
Indexa Capital
- Binance appears in the institution list for **all countries**, with
the full list sorted alphabetically
- Creates a single "Crypto Portfolio" investment account with total
portfolio value converted to the user's preferred currency
- Supports direct fiat pairs (e.g. BTCEUR), USD stablecoin 1:1 mapping,
and USDT fallback conversion

## Changes

- **Migration**: adds encrypted `api_secret` column to
`banking_connections`
- **BinanceClient**: HMAC-SHA256 authenticated API client for account
data and ticker prices
- **BinanceBalanceSyncService**: converts all non-zero balances to fiat
via direct pairs or USDT fallback
- **BinanceController + ConnectBinanceRequest**: validates credentials,
creates connection and single account
- **SyncBankingConnectionJob**: new `syncBinance()` branch
- **AccountMappingController**: Binance uses Investment account type
- **Frontend**: Binance institution for all countries, API Key + Secret
form fields, alphabetically sorted list
- **Factory**: `binance()` state on `BankingConnectionFactory`

## Test plan

- [x] `BinanceControllerTest` — 6 tests (valid connection, invalid
credentials, account-mapping flag, feature flag, validation, user
currency)
- [x] `BinanceBalanceSyncTest` — 7 tests (direct EUR pair, USDT
fallback, USD stablecoins, locked balances, same-date update, empty
balances, missing external ID)
- [x] Full test suite passes (545 tests)
- [x] Manual: open connection dialog → select any country → Binance
appears alphabetically → select Binance → API Key + Secret form →
connect
2026-02-18 15:23:46 +01:00
Víctor Falcón 3f541ca4d6
feat: Add Indexa Capital integration (#130)
## Summary

- Allow users to connect their Indexa Capital (Spanish robo-advisor)
account via API token
- Adds "Indexa Capital" option when selecting Spain in the bank
connection dialog
- Token-based auth flow: user enters API token (instead of OAuth
redirect), validated against Indexa API, stored encrypted
- Syncs portfolio balance only (no transactions) from the
`/accounts/{id}/performance` endpoint
- Accounts created as `Investment` type
- Reuses existing account mapping flow when the `account-mapping`
feature flag is active
- Disconnect flow skips session revocation for Indexa (no OAuth session
to revoke)

## New files
- `IndexaCapitalClient` — HTTP client for Indexa Capital API
(`X-AUTH-TOKEN`)
- `IndexaCapitalBalanceSyncService` — Syncs portfolio value into
`account_balances`
- `IndexaCapitalController` — Token validation + connection creation
- `ConnectIndexaCapitalRequest` — Form request validation
- Migration adding encrypted `api_token` column to `banking_connections`

## Test plan
- [x] 6 controller tests (connect, invalid token, mapping, feature flag,
validation, multi-account)
- [x] 4 balance sync tests (sync, update, skip, missing field)
- [x] 3 sync job tests (balance-only, no expire, no email for Indexa)
- [x] 1 disconnect test (no revokeSession for Indexa)
- [x] All 530 existing tests still pass
2026-02-18 10:42:13 +01:00
Víctor Falcón 40d4b3cfe7
Email notification for bank-synced transactions (#113)
## Summary

- Send a queued email notification to users after subsequent bank syncs
when new transactions are found
- Email includes a per-bank breakdown of imported transaction counts
with a link to the transactions page
- Skips notification on first sync and when zero new transactions are
created

## Test plan

- [x] Sends email when new transactions are synced on subsequent sync
- [x] Does not send email on first sync
- [x] Does not send email when zero new transactions
- [x] Aggregates multiple accounts under same bank
- [x] Lists different banks separately in email
- [x] Existing sync job tests still pass
2026-02-12 14:05:02 +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 fee7ad36ab
feat: Load transactions history on budget created (#72) 2026-01-22 11:10:15 +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 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 46c5b13739 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-30 07:22:18 +01:00